feat: rwfx 任务深链支持目的树闸门 + 任务↔对话(1:N)绑定
后端 - config.yaml 新增 task_deeplink 段:完整 URL(无 base)+ test/delete_test 两个独立测试开关 + mock 假数据(目的树 / delete 结果,可随时编辑) - 新增 TaskDeeplinkConfig 模型挂到 AppConfig;新增公开只读接口 GET /api/public/config/task-deeplink 暴露给前端(/api/public 已在鉴权白名单) 前端 - 登录深链 goPath=rwfx 进入「任务工作区」:左侧按 metadata.taskId 过滤的对话列表 (新建对话 / 编辑 / 下一步 / 清除任务记录)+ 右侧问答 - 新建对话(含首次进入)顶部展示目的树闸门:有数据弹覆盖确认→调 delete,success 才开始问答; 恢复历史对话不跑闸门 - 新建 thread 打 metadata.taskId 标签实现「一个任务挂多个对话」(1:N) - 地址/开关/假数据全部走后端配置,拿不到时用内置默认值兜底;test/delete_test=true 走假数据 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5b610fd807
commit
a2d767e2db
15 files changed
+907
-36
No files matched your search
@@ -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<string, unknown>) : {};
|
||||||
|
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<string, unknown>) : {};
|
||||||
|
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<TaskDeeplinkConfig> | null = null;
|
||||||
|
|
||||||
|
/** 拉取(带模块级缓存 + 去重);失败回退默认值。 */
|
||||||
|
export async function fetchTaskDeeplinkConfig(): Promise<TaskDeeplinkConfig> {
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
@@ -16,8 +16,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { requestCrudService } from "@/service/request-axios-service";
|
import { requestCrudService } from "@/service/request-axios-service";
|
||||||
|
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
|
||||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||||
|
|
||||||
|
/** rwfx 业务码:单智能体走「任务工作区 + 目的树闸门」(其它业务保持原问答逻辑)。 */
|
||||||
|
const RWFX_BUSINESS_CODE = "6BF";
|
||||||
|
|
||||||
/** consumer 接口返回的任务详情(data 字段)。 */
|
/** consumer 接口返回的任务详情(data 字段)。 */
|
||||||
export interface CopTaskDetail {
|
export interface CopTaskDetail {
|
||||||
id: string | number | null;
|
id: string | number | null;
|
||||||
@@ -132,12 +136,16 @@ function buildOpeningText(detail: CopTaskDetail): string {
|
|||||||
export async function resolveTaskDeeplinkTarget(
|
export async function resolveTaskDeeplinkTarget(
|
||||||
goPath: string,
|
goPath: string,
|
||||||
detail: CopTaskDetail,
|
detail: CopTaskDetail,
|
||||||
|
taskId: string,
|
||||||
): Promise<TaskDeeplinkTarget | null> {
|
): Promise<TaskDeeplinkTarget | null> {
|
||||||
const parsed = parseGoPath(goPath);
|
const parsed = parseGoPath(goPath);
|
||||||
if (!parsed) return null;
|
if (!parsed) return null;
|
||||||
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
|
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
|
||||||
if (!taskBase) return null;
|
if (!taskBase) return null;
|
||||||
|
|
||||||
|
// 清掉上一次深链可能残留的任务上下文(仅 rwfx 单智能体会重新写入)。
|
||||||
|
clearTaskContext();
|
||||||
|
|
||||||
if (parsed.roundtable) {
|
if (parsed.roundtable) {
|
||||||
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。
|
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。
|
||||||
return {
|
return {
|
||||||
@@ -162,6 +170,15 @@ export async function resolveTaskDeeplinkTarget(
|
|||||||
}
|
}
|
||||||
if (!agentId) return null;
|
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 {
|
try {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
"pendingAgentMessage",
|
"pendingAgentMessage",
|
||||||
@@ -170,6 +187,5 @@ export async function resolveTaskDeeplinkTarget(
|
|||||||
} catch {
|
} catch {
|
||||||
/* noop */
|
/* noop */
|
||||||
}
|
}
|
||||||
// 单智能体问答:同样嵌进对应业务的「任务管理」布局。
|
|
||||||
return { path: `${taskBase}/agent/${agentId}/chats/new` };
|
return { path: `${taskBase}/agent/${agentId}/chats/new` };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* 任务深链(``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<string, { label: string; textKey: string }> = {
|
||||||
|
purposeDto: { label: "目的", textKey: "zzPurpose" },
|
||||||
|
actorDto: { label: "行为体", textKey: "actorName" },
|
||||||
|
expectedBehaviorDto: { label: "预期行为", textKey: "behavior" },
|
||||||
|
drivingFactorDto: { label: "驱动因素", textKey: "drivingFactor" },
|
||||||
|
narrationDto: { label: "叙事", textKey: "narration" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeNode(raw: unknown): PurposeTreeNode | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
// 找出本节点携带的 dto 字段(目的/行为体/...)。
|
||||||
|
const dtoKey = Object.keys(DTO_FIELD_MAP).find(
|
||||||
|
(k) => obj[k] && typeof obj[k] === "object",
|
||||||
|
);
|
||||||
|
if (!dtoKey) return null;
|
||||||
|
const { label, textKey } = DTO_FIELD_MAP[dtoKey];
|
||||||
|
const dto = obj[dtoKey] as Record<string, unknown>;
|
||||||
|
const text = typeof dto[textKey] === "string" ? (dto[textKey] as string) : "";
|
||||||
|
const reason =
|
||||||
|
typeof dto.recReason === "string" && dto.recReason.trim()
|
||||||
|
? (dto.recReason as string)
|
||||||
|
: undefined;
|
||||||
|
const childrenRaw = Array.isArray(obj.childrenList) ? obj.childrenList : [];
|
||||||
|
const children = childrenRaw
|
||||||
|
.map(normalizeNode)
|
||||||
|
.filter((n): n is PurposeTreeNode => n !== null);
|
||||||
|
return { label, text, reason, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从接口/假数据的整包 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<TaskPurposeResult> {
|
||||||
|
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<DeleteTaskPurposeResult> {
|
||||||
|
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<string, unknown>) : {};
|
||||||
|
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<TaskContext>;
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ export type ThreadStreamOptions = {
|
|||||||
context: LocalSettings["context"];
|
context: LocalSettings["context"];
|
||||||
isMock?: boolean;
|
isMock?: boolean;
|
||||||
assistantId?: string;
|
assistantId?: string;
|
||||||
|
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
|
||||||
|
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
||||||
|
threadMetadata?: Record<string, unknown>;
|
||||||
onStart?: (threadId: string) => void;
|
onStart?: (threadId: string) => void;
|
||||||
onFinish?: (state: AgentThreadState) => void;
|
onFinish?: (state: AgentThreadState) => void;
|
||||||
onToolEnd?: (event: ToolEndEvent) => void;
|
onToolEnd?: (event: ToolEndEvent) => void;
|
||||||
@@ -200,6 +203,7 @@ export function useThreadStream({
|
|||||||
context,
|
context,
|
||||||
isMock,
|
isMock,
|
||||||
assistantId,
|
assistantId,
|
||||||
|
threadMetadata,
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
@@ -277,11 +281,15 @@ export function useThreadStream({
|
|||||||
handleStreamStart(meta.thread_id);
|
handleStreamStart(meta.thread_id);
|
||||||
setOnStreamThreadId(meta.thread_id);
|
setOnStreamThreadId(meta.thread_id);
|
||||||
const metadataAgentId = context.agent_id ?? context.agent_name;
|
const metadataAgentId = context.agent_id ?? context.agent_name;
|
||||||
if (metadataAgentId && !isMock) {
|
const extraMetadata =
|
||||||
|
threadMetadata && typeof threadMetadata === "object" ? threadMetadata : {};
|
||||||
|
const metadata: Record<string, unknown> = {
|
||||||
|
...(metadataAgentId ? { agent_id: metadataAgentId } : {}),
|
||||||
|
...extraMetadata,
|
||||||
|
};
|
||||||
|
if (Object.keys(metadata).length > 0 && !isMock) {
|
||||||
void getAPIClient()
|
void getAPIClient()
|
||||||
.threads.update(meta.thread_id, {
|
.threads.update(meta.thread_id, { metadata })
|
||||||
metadata: { agent_id: metadataAgentId },
|
|
||||||
})
|
|
||||||
.catch(() => ({}));
|
.catch(() => ({}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,11 +29,14 @@ import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
|||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { env } from "@/env";
|
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";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
|
||||||
export default function AgentChatPage({
|
export default function AgentChatPage({
|
||||||
embeddedAgentId,
|
embeddedAgentId,
|
||||||
chatBasePath,
|
chatBasePath,
|
||||||
|
taskGate,
|
||||||
}: {
|
}: {
|
||||||
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
|
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
|
||||||
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
|
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
|
||||||
@@ -43,6 +46,9 @@ export default function AgentChatPage({
|
|||||||
* 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`,
|
* 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`,
|
||||||
* 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */
|
* 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */
|
||||||
chatBasePath?: string;
|
chatBasePath?: string;
|
||||||
|
/** rwfx 任务深链闸门:新建对话时顶部展示目的树面板(覆盖确认后才开始问答),
|
||||||
|
* 并给新建 thread 打 `metadata.taskId` 标签(任务↔对话 1:N)。见 task-purpose。 */
|
||||||
|
taskGate?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const routeBase = useRouteBase();
|
const routeBase = useRouteBase();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -57,6 +63,16 @@ export default function AgentChatPage({
|
|||||||
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
|
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
|
||||||
const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`;
|
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
|
// 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
|
// screen on first render so the user doesn't see a flash of the welcome UI
|
||||||
// before the auto-sent message takes over.
|
// before the auto-sent message takes over.
|
||||||
@@ -101,6 +117,8 @@ export default function AgentChatPage({
|
|||||||
threadId: isNewThread ? undefined : threadId,
|
threadId: isNewThread ? undefined : threadId,
|
||||||
assistantId: agent_id,
|
assistantId: agent_id,
|
||||||
context: { ...settings.context, agent_id },
|
context: { ...settings.context, agent_id },
|
||||||
|
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤。
|
||||||
|
threadMetadata: taskId ? { taskId } : undefined,
|
||||||
onStart: (createdThreadId) => {
|
onStart: (createdThreadId) => {
|
||||||
setThreadId(createdThreadId);
|
setThreadId(createdThreadId);
|
||||||
setIsNewThread(false);
|
setIsNewThread(false);
|
||||||
@@ -143,6 +161,14 @@ export default function AgentChatPage({
|
|||||||
[sendMessage, threadId, agent_id],
|
[sendMessage, threadId, agent_id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则
|
||||||
|
// 仅切到对话态(隐藏面板),让用户手动输入。
|
||||||
|
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
|
// 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
|
// 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.
|
// user doesn't have to re-type the question on the agent chat page.
|
||||||
@@ -181,6 +207,8 @@ export default function AgentChatPage({
|
|||||||
}, [thread]);
|
}, [thread]);
|
||||||
|
|
||||||
const isLandingState = isNewThread && !hasStartedConversation;
|
const isLandingState = isNewThread && !hasStartedConversation;
|
||||||
|
// 任务闸门面板:仅在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
||||||
|
const showTaskGate = !!taskId && isNewThread && !hasStartedConversation;
|
||||||
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||||
|
|
||||||
const status = thread.error
|
const status = thread.error
|
||||||
@@ -245,6 +273,10 @@ export default function AgentChatPage({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{showTaskGate && (
|
||||||
|
<TaskPurposePanel taskId={taskId} onStart={handleGateStart} />
|
||||||
|
)}
|
||||||
|
|
||||||
{isLandingState ? (
|
{isLandingState ? (
|
||||||
/* Home: centered hero, suggested questions, and input.
|
/* Home: centered hero, suggested questions, and input.
|
||||||
Everything lives inside the scrollable main column. No fixed,
|
Everything lives inside the scrollable main column. No fixed,
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export default function LoginPage() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (detail) {
|
if (detail) {
|
||||||
persistCopTaskDetail(detail);
|
persistCopTaskDetail(detail);
|
||||||
const target = await resolveTaskDeeplinkTarget(goPath, detail);
|
const target = await resolveTaskDeeplinkTarget(goPath, detail, taskId);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (target) {
|
if (target) {
|
||||||
navigate(target.path, { replace: true, state: target.state });
|
navigate(target.path, { replace: true, state: target.state });
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgF
|
|||||||
import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyFrom";
|
import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyFrom";
|
||||||
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
|
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
|
||||||
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
|
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 { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
|
||||||
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
|
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
|
||||||
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
|
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
|
||||||
@@ -162,12 +167,26 @@ export default function StrategyRoutes() {
|
|||||||
</ChatRuntime>
|
</ChatRuntime>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* 任务FX:任务工作区(左对话列表 1:N + 右问答;新建对话顶部目的树闸门)。 */}
|
||||||
<Route
|
<Route
|
||||||
path="plan-task/plan-task-fx/agent/:agent_id/chats/:thread_id"
|
path="plan-task/plan-task-fx/agent/:agent_id/chats"
|
||||||
element={
|
element={
|
||||||
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
|
<TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
|
<TaskFxIndexResolver taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path=":thread_id"
|
||||||
|
element={
|
||||||
|
<TaskFxChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path="plan-task/plan-task-action"
|
path="plan-task/plan-task-action"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。
|
||||||
|
*
|
||||||
|
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+ 任务级操作
|
||||||
|
* (新建对话 / 编辑 / 下一步 / 清除任务记录);
|
||||||
|
* 右侧 = <Outlet/> 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。
|
||||||
|
*
|
||||||
|
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。
|
||||||
|
* - 新建对话 = 直接跳入:navigate 到 chats/new,AgentChatPage 闸门态展示目的树面板。
|
||||||
|
* - 清除任务记录:仅调 delete 接口(聊天对话保留)。
|
||||||
|
* - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。
|
||||||
|
* 见 [[task-purpose]] / [[task-deeplink-config]]。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Outlet, 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` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 左侧任务对话列表 + 任务级操作。 */
|
||||||
|
function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) {
|
||||||
|
const { thread_id } = useParams<{ thread_id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { threads, isLoading } = useTaskThreads(taskId);
|
||||||
|
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 (
|
||||||
|
<div className="border-border/70 bg-card/40 flex w-60 shrink-0 flex-col border-r">
|
||||||
|
<div className="border-border/70 flex flex-col gap-1.5 border-b p-2">
|
||||||
|
<Button size="sm" onClick={() => navigate(`${chatsBase}/new`)}>
|
||||||
|
<PlusSquare className="h-4 w-4" /> 新建对话
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" /> 编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" /> 下一步
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive justify-start"
|
||||||
|
onClick={() => setClearOpen(true)}
|
||||||
|
disabled={!taskId}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> 清除任务记录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground p-2 text-sm">加载中…</div>
|
||||||
|
) : threads.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground p-2 text-sm">暂无对话</div>
|
||||||
|
) : (
|
||||||
|
threads.map((thread) => (
|
||||||
|
<button
|
||||||
|
key={thread.thread_id}
|
||||||
|
onClick={() => navigate(`${chatsBase}/${thread.thread_id}`)}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
|
||||||
|
thread.thread_id === thread_id && "bg-accent font-medium",
|
||||||
|
)}
|
||||||
|
title={desensitize(titleOfThread(thread, "新对话"))}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{desensitize(titleOfThread(thread, "新对话"))}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={clearOpen} onOpenChange={(o) => !clearing && setClearOpen(o)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>清除任务记录</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
将清除该任务在 B 系统中的目的策划数据(聊天对话保留),是否确认?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setClearOpen(false)} disabled={clearing}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleClear} disabled={clearing}>
|
||||||
|
{clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务工作区布局:左列表 + 右 Outlet。 */
|
||||||
|
export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
|
||||||
|
const { taskId, chatsBase } = useChatsBase(taskBase);
|
||||||
|
// 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchTaskDeeplinkConfig();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="flex size-full min-h-0 overflow-hidden">
|
||||||
|
<TaskConversationsList taskId={taskId} chatsBase={chatsBase} />
|
||||||
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="text-muted-foreground flex size-full items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板)。 */
|
||||||
|
export function TaskFxChat({ taskBase }: { taskBase: string }) {
|
||||||
|
const { agent_id } = useParams<{ agent_id: string }>();
|
||||||
|
return (
|
||||||
|
<ChatRuntime>
|
||||||
|
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskGate />
|
||||||
|
</ChatRuntime>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* 任务深链(rwfx)「新建对话」前的目的树闸门面板(嵌在问答页顶部)。
|
||||||
|
*
|
||||||
|
* 流程:拉目的树并展示 → 点「开始问答」:
|
||||||
|
* - 已有数据 → 弹「将被覆盖,确认?」→ 确认调 delete:success=true 才 onStart();
|
||||||
|
* success=false 提示后端 message(如「行动已经开始策划,不可覆盖」)并停留。
|
||||||
|
* - 无数据 → 直接 onStart()。
|
||||||
|
* onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Loader2 } 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,
|
||||||
|
type PurposeTreeNode,
|
||||||
|
type TaskPurposeResult,
|
||||||
|
} from "@/core/auth/task-purpose";
|
||||||
|
|
||||||
|
function PurposeNodes({ nodes, depth = 0 }: { nodes: PurposeTreeNode[]; depth?: number }) {
|
||||||
|
return (
|
||||||
|
<ul className={depth === 0 ? "space-y-1" : "mt-1 space-y-1 border-l border-border/60 pl-3"}>
|
||||||
|
{nodes.map((node, i) => (
|
||||||
|
<li key={`${depth}-${i}-${node.text}`} className="text-sm leading-relaxed">
|
||||||
|
<span className="text-muted-foreground mr-1 rounded bg-muted px-1 py-0.5 text-xs">
|
||||||
|
{node.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{node.text || "(空)"}</span>
|
||||||
|
{node.reason ? (
|
||||||
|
<span className="text-muted-foreground ml-2 text-xs">理由:{node.reason}</span>
|
||||||
|
) : null}
|
||||||
|
{node.children.length > 0 ? (
|
||||||
|
<PurposeNodes nodes={node.children} depth={depth + 1} />
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskPurposePanel({
|
||||||
|
taskId,
|
||||||
|
onStart,
|
||||||
|
}: {
|
||||||
|
taskId: string;
|
||||||
|
/** 闸门通过(无数据 / 覆盖成功),开始问答。 */
|
||||||
|
onStart: () => void;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [result, setResult] = useState<TaskPurposeResult | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [working, setWorking] = 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-border/70 bg-card/60 shrink-0 border-b px-4 py-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">任务目的(任务 {taskId})</span>
|
||||||
|
<Button size="sm" onClick={handleStart} disabled={loading || working}>
|
||||||
|
{result?.hasData ? "覆盖并开始问答" : "开始问答"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
|
||||||
|
</div>
|
||||||
|
) : result && result.tree.length > 0 ? (
|
||||||
|
<div className="max-h-48 overflow-y-auto pr-1">
|
||||||
|
<PurposeNodes nodes={result.tree} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground py-1 text-sm">暂无目的数据,可直接开始问答。</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={confirmOpen} onOpenChange={(o) => !working && setConfirmOpen(o)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认覆盖</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
检测到该任务已有目的数据,继续问答将覆盖原有数据,是否确认?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setConfirmOpen(false)} disabled={working}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirmOverwrite} disabled={working}>
|
||||||
|
{working ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认覆盖"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TaskPurposePanel;
|
||||||
@@ -51,6 +51,7 @@ from app.gateway.routers import (
|
|||||||
parallel_agent_tasks,
|
parallel_agent_tasks,
|
||||||
parallel_agents,
|
parallel_agents,
|
||||||
positions,
|
positions,
|
||||||
|
public_config,
|
||||||
public_embed,
|
public_embed,
|
||||||
public_leaderboard,
|
public_leaderboard,
|
||||||
public_scheduled_tasks,
|
public_scheduled_tasks,
|
||||||
@@ -1368,6 +1369,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
|
|
||||||
# Public embed Q&A status polling (/api/public/embed/*)
|
# Public embed Q&A status polling (/api/public/embed/*)
|
||||||
app.include_router(public_embed.router)
|
app.include_router(public_embed.router)
|
||||||
|
app.include_router(public_config.router)
|
||||||
app.include_router(html_page_favorites.router)
|
app.include_router(html_page_favorites.router)
|
||||||
|
|
||||||
# Product knowledge base API is mounted at /api/knowledge
|
# Product knowledge base API is mounted at /api/knowledge
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -15,10 +15,10 @@ from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpo
|
|||||||
from deerflow.config.database_config import DatabaseConfig
|
from deerflow.config.database_config import DatabaseConfig
|
||||||
from deerflow.config.extensions_config import ExtensionsConfig
|
from deerflow.config.extensions_config import ExtensionsConfig
|
||||||
from deerflow.config.gateway_config import GatewayConfig
|
from deerflow.config.gateway_config import GatewayConfig
|
||||||
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
|
||||||
from deerflow.config.knowledge_config import KnowledgeConfig
|
from deerflow.config.knowledge_config import KnowledgeConfig
|
||||||
from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
|
from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
|
||||||
from deerflow.config.log_system_config import LogSystemConfig
|
from deerflow.config.log_system_config import LogSystemConfig
|
||||||
from deerflow.config.login_config import AuthLoginConfig
|
from deerflow.config.login_config import AuthLoginConfig
|
||||||
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
|
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
|
||||||
from deerflow.config.model_config import ModelConfig
|
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.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.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
|
||||||
from deerflow.config.summarization_config import SummarizationConfig, load_summarization_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.title_config import TitleConfig, load_title_config_from_dict
|
||||||
from deerflow.config.token_usage_config import TokenUsageConfig
|
from deerflow.config.token_usage_config import TokenUsageConfig
|
||||||
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
|
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")
|
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
|
||||||
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
|
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
|
||||||
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge 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")
|
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: 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")
|
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
|
@classmethod
|
||||||
def resolve_config_path(cls, config_path: str | None = None) -> Path:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
|
def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
|
||||||
"""Load config from disk and refresh cache metadata."""
|
"""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
|
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)
|
resolved_path = AppConfig.resolve_config_path(config_path)
|
||||||
_app_config = AppConfig.from_file(str(resolved_path))
|
_app_config = AppConfig.from_file(str(resolved_path))
|
||||||
_app_config_path = resolved_path
|
_app_config_path = resolved_path
|
||||||
_app_config_mtime = _get_config_mtime(resolved_path)
|
_app_config_mtime = _get_config_mtime(resolved_path)
|
||||||
_app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime()
|
_app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime()
|
||||||
_app_config_is_custom = False
|
_app_config_is_custom = False
|
||||||
summarization = _app_config.summarization
|
summarization = _app_config.summarization
|
||||||
logger.info(
|
logger.info(
|
||||||
"Loaded AppConfig from %s; summarization enabled=%s trigger=%s keep=%s trim_tokens_to_summarize=%s",
|
"Loaded AppConfig from %s; summarization enabled=%s trigger=%s keep=%s trim_tokens_to_summarize=%s",
|
||||||
resolved_path,
|
resolved_path,
|
||||||
summarization.enabled,
|
summarization.enabled,
|
||||||
summarization.trigger,
|
summarization.trigger,
|
||||||
summarization.keep,
|
summarization.keep,
|
||||||
summarization.trim_tokens_to_summarize,
|
summarization.trim_tokens_to_summarize,
|
||||||
)
|
)
|
||||||
return _app_config
|
return _app_config
|
||||||
|
|
||||||
|
|
||||||
def get_app_config() -> AppConfig:
|
def get_app_config() -> AppConfig:
|
||||||
|
|||||||
+51
@@ -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}}",
|
||||||
|
)
|
||||||
@@ -324,3 +324,44 @@ summarization:
|
|||||||
type: tokens
|
type: tokens
|
||||||
value: 40000
|
value: 40000
|
||||||
trim_tokens_to_summarize: 8000
|
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
|
||||||
Reference in new issue
Block a user