444 lines
19 KiB
TypeScript
444 lines
19 KiB
TypeScript
/**
|
||
* 任务深链(``goPath=rwfx``)的「目的树闸门 + 任务↔对话绑定」支持。
|
||
*
|
||
* 角色区分:
|
||
* - **目的树数据**:B 系统里的策划结果,走 ``getTaskPurposeDetail`` / ``delete``(地址、测试
|
||
* 开关、测试假数据都来自 [[task-deeplink-config]] / 后端 config.yaml),与聊天对话无关。
|
||
* 新建对话(含首次进入)前用它做「已有数据→覆盖确认」闸门。
|
||
* - **对话(thread)**:聊天会话;真实消息仍是 LangGraph thread,3qfx/rwfx/xdfx 的历史列表
|
||
* 通过 ``taskRecordId + taskGoPath`` 专用索引读取,不再过滤通用会话表。详见 [[task-deeplink]]。
|
||
*/
|
||
|
||
import {
|
||
appendQueryParam,
|
||
fetchTaskDeeplinkConfig,
|
||
getCachedTaskDeeplinkConfig,
|
||
} from "@/core/auth/task-deeplink-config";
|
||
import { requestCrudService } from "@/service/request-axios-service";
|
||
import type {
|
||
FlowGraphData,
|
||
FlowGraphEdge,
|
||
FlowGraphNode,
|
||
FlowNodeType,
|
||
} from "@/roundtable-planning/lib/flow-graph";
|
||
|
||
/** 归一化后的目的树节点(用于展示,逐层:目的→行为体→预期行为→驱动因素→叙事)。 */
|
||
export interface PurposeTreeNode {
|
||
/** 节点类型中文标签。 */
|
||
label: string;
|
||
/** 节点主文本(目的/行为体名/预期/因素/叙事内容)。 */
|
||
text: string;
|
||
/** 推荐理由(若有)。 */
|
||
reason?: string;
|
||
children: PurposeTreeNode[];
|
||
}
|
||
|
||
/**
|
||
* The base (without `-A`) of a task deep-link goPath.
|
||
*
|
||
* Besides the four built-ins, administrators may now create a short custom
|
||
* mapping name. Keeping this a string lets its dedicated conversation history
|
||
* partition follow the configured goPath without adding frontend code again.
|
||
*/
|
||
export type TaskDeeplinkGoPath = string;
|
||
|
||
function readTaskDeeplinkGoPath(value: unknown): TaskDeeplinkGoPath | undefined {
|
||
if (typeof value !== "string") return undefined;
|
||
const normalized = value.trim().toLowerCase();
|
||
return normalized && normalized.length <= 32 && !normalized.endsWith("-a")
|
||
? normalized
|
||
: undefined;
|
||
}
|
||
|
||
/** 当前任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
|
||
export interface TaskContext {
|
||
taskId: string;
|
||
agentId: string;
|
||
/** 自动发出的开场信息(任务名称/内容)。 */
|
||
openingText: string;
|
||
/** 深链原始 id(xdfx 为「行动id」,需追加行动id 的按钮用;其它业务等于 taskId)。 */
|
||
actionId?: string;
|
||
/**
|
||
* xdfx(行动分析)专属:聊天记录按「行动id」(actionId) 而非「任务id」划分。
|
||
* 为 true 时,新建对话额外打 `metadata.actionId` 标签、对话列表按 actionId 过滤
|
||
* (仍保留 taskId 标签以维持任务深链对话「全局共享、不分用户」的后端语义)。
|
||
*/
|
||
partitionByAction?: boolean;
|
||
/** 单智能体深链接类型;用于写入专用 taskId + goPath 聊天记录索引。 */
|
||
goPath?: TaskDeeplinkGoPath;
|
||
/**
|
||
* b5-1 深链 ``?type=`` 业务类型。聊天记录在 taskId + goPath 之上再按该类型
|
||
* 细分分区(新对话打 `metadata.taskType` 标签、列表按 sub_type 过滤),
|
||
* 不同类型的对话互不可见。仅 b5-1 使用,其它深链不设置。
|
||
*/
|
||
taskType?: 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" },
|
||
};
|
||
|
||
// 取节点主文本时跳过的字段(id/时间/标记/理由等非展示文本),保证容错。
|
||
const SKIP_TEXT_KEY = /(^id$|Id$|Time$|Flag$|Type$|Level$|recReason|createBy|updateBy|^mid$)/i;
|
||
|
||
/** 从一个 dto 里取展示文本:优先 preferredKey,否则取第一个像「内容」的字符串字段。 */
|
||
function pickDtoText(dto: Record<string, unknown>, 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<string, unknown>;
|
||
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<string, unknown>;
|
||
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;
|
||
}
|
||
|
||
/* --------------------- 目的树 → 流程图(flow-graph) --------------------- */
|
||
|
||
/**
|
||
* 目的树各层级标签 → 流程图节点类型(仅决定配色,见 FLOW_NODE_TYPE_META)。
|
||
* 未知层级回退 task。层级名本身仍原样写进节点 group,画布上当小标签显示。
|
||
*/
|
||
const PURPOSE_LABEL_TO_FLOW_TYPE: Record<string, FlowNodeType> = {
|
||
目的: "stage",
|
||
行为体: "role",
|
||
预期行为: "task",
|
||
驱动因素: "decision",
|
||
叙事: "deliverable",
|
||
};
|
||
|
||
/** 目的树的一个层级(深度):层级中文名 + 对应流程图节点类型(决定配色)。 */
|
||
export interface PurposeLevel {
|
||
/** 层级中文名(目的 / 行为体 / 预期行为 / 驱动因素 / 叙事…)。 */
|
||
label: string;
|
||
/** 流程图节点类型(用于取 FLOW_NODE_TYPE_META 配色,与画布节点一致)。 */
|
||
type: FlowNodeType;
|
||
}
|
||
|
||
/**
|
||
* 按**深度**抽出目的树的有序层级(每层取该深度第一个非空层级名作代表)。
|
||
*
|
||
* 目的树每一深度的层级是一致的(深度 0 = 目的、1 = 行为体、… 直到叶子叙事),
|
||
* 故逐层 BFS、每层取一个代表标签即可得到「任务策划共几级、分别是什么」,
|
||
* 供消息列表上方的「流程示例图」按层级画彩色小图标。
|
||
*/
|
||
export function derivePurposeLevels(tree: PurposeTreeNode[]): PurposeLevel[] {
|
||
const levels: PurposeLevel[] = [];
|
||
let frontier = tree;
|
||
let depth = 0;
|
||
while (frontier.length > 0 && depth < 12) {
|
||
const label = frontier.find((n) => n.label?.trim())?.label?.trim() || `层级 ${depth + 1}`;
|
||
levels.push({ label, type: PURPOSE_LABEL_TO_FLOW_TYPE[label] ?? "task" });
|
||
frontier = frontier.flatMap((n) => n.children ?? []);
|
||
depth += 1;
|
||
}
|
||
return levels;
|
||
}
|
||
|
||
/**
|
||
* 把归一化后的目的树转换成 Step 3 流程图组件(FlowGraphPanel)认识的 FlowGraphData。
|
||
*
|
||
* - 逐层深度优先遍历,给每个节点分配稳定 id(`p1/p2/…`,按遍历顺序);
|
||
* - 父→子建一条边(from=父 id);
|
||
* - 节点主文本(`text`)作 label,层级名(`label`)作 group(画布顶部小标签),
|
||
* 推荐理由并进 detail(点节点弹窗里看)。
|
||
* - 传入 ``rootLabel`` 时,在最顶层加一个「任务」根节点,所有「目的」挂到它下面
|
||
* (与 B 系统给的「任务 → 目的 → 行为体 → …」流程一致,单一根节点);不传则保持
|
||
* 旧的「多个目的各自成根」森林(向后兼容)。
|
||
* 容错沿用 PurposeTreeNode(normalizeNode 已兜底字段/层级变动),这里不再二次判断。
|
||
*/
|
||
export function purposeTreeToFlowGraph(
|
||
tree: PurposeTreeNode[],
|
||
taskId?: string,
|
||
rootLabel?: string,
|
||
): FlowGraphData {
|
||
const nodes: FlowGraphNode[] = [];
|
||
const edges: FlowGraphEdge[] = [];
|
||
let counter = 0;
|
||
const walk = (node: PurposeTreeNode, parentId: string | null) => {
|
||
const id = `p${++counter}`;
|
||
const type = PURPOSE_LABEL_TO_FLOW_TYPE[node.label] ?? "task";
|
||
const text = node.text?.trim() || node.label || "(空)";
|
||
const detail = node.reason?.trim()
|
||
? `${text}\n理由:${node.reason.trim()}`
|
||
: node.text?.trim() || undefined;
|
||
nodes.push({ id, label: text, type, group: node.label || undefined, detail });
|
||
if (parentId) edges.push({ from: parentId, to: id });
|
||
node.children?.forEach((child) => walk(child, id));
|
||
};
|
||
// 任务为单一根节点(仅在有目的数据且传入 rootLabel 时):每个「目的」根挂到它下面。
|
||
const rootId = rootLabel && rootLabel.trim() && tree.length > 0 ? "task-root" : null;
|
||
if (rootId) {
|
||
nodes.push({ id: rootId, label: rootLabel!.trim(), type: "start", group: "任务" });
|
||
}
|
||
tree.forEach((root) => walk(root, rootId));
|
||
return {
|
||
title: taskId ? `任务目的(任务 ${taskId})` : "任务目的",
|
||
nodes,
|
||
edges,
|
||
};
|
||
}
|
||
|
||
/** 从接口/假数据的整包 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;
|
||
}
|
||
|
||
/**
|
||
* 删除记录的「按哪种 id 删」:
|
||
* - ``task``(默认,rwfx):传 ``?taskId=``,不传 actionId;
|
||
* - ``action``(xdfx 行动分析):传 ``?actionId=``,不传 taskId。
|
||
* rwfx 与 xdfx 共用同一个删除接口(``delete_url`` = dropSixOrSevenData);
|
||
* 「不传」= 该参数**根本不出现在 URL 里**(只把要传的那个 key 放进 params,axios 自然省略另一个),
|
||
* 不是传空值/None。
|
||
*/
|
||
export type DeleteIdKind = "task" | "action";
|
||
|
||
/**
|
||
* 删除任务/行动记录(rwfx 覆盖前清空 / 手动清除 / xdfx 清除行动记录都走这里)。
|
||
* ``delete_test=true`` 时用后端配置里的假数据(接口未开发完时用);否则打真实接口
|
||
* (rwfx 传 ``taskId``、xdfx 传 ``actionId``,见 ``DeleteIdKind``)。
|
||
*/
|
||
export async function deleteTaskPurpose(
|
||
id: string,
|
||
opts?: { idKind?: DeleteIdKind },
|
||
): Promise<DeleteTaskPurposeResult> {
|
||
const idKind = opts?.idKind ?? "task";
|
||
if (!id) return { message: idKind === "action" ? "缺少 actionId" : "缺少 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 };
|
||
// 只放要传的那个 id key:另一参数直接不出现在 URL(axios 不会序列化缺省 key)。
|
||
const params = idKind === "action" ? { actionId: id } : { taskId: id };
|
||
// 该删除接口(dropSixOrSevenData)成功时**什么都不返回**(空响应体),所以只要请求没抛错
|
||
// (HTTP 成功)就直接判定为成功;仅当后端偶尔回了 {success:false} 这类显式失败标记才算失败。
|
||
const body = await requestCrudService.get(cfg.deleteUrl, params);
|
||
const obj =
|
||
body && typeof body === "object" ? (body as unknown as Record<string, unknown>) : {};
|
||
return {
|
||
message: typeof obj.message === "string" ? obj.message : "",
|
||
success: obj.success !== false,
|
||
};
|
||
}
|
||
|
||
export interface ActionHistoryResult {
|
||
/** 是否存在可删除的「行动历史」记录。 */
|
||
exists: boolean;
|
||
/** 是否成功判定。接口未配置 / 请求失败 → false(调用方据此**不**做「无历史」拦截,仍允许删除)。 */
|
||
known: boolean;
|
||
}
|
||
|
||
/**
|
||
* 查询 xdfx「行动历史是否存在」(清除行动记录前的预检),按「行动id」调用。
|
||
*
|
||
* 查询接口(``action_history_url``)地址**目前待提供**:为空时直接返回
|
||
* ``{ exists: false, known: false }``——视作空白、不查询,也不阻断删除;后端配上真实地址后自动生效。
|
||
*
|
||
* ⚠️ 真实返回结构待定:拿到接口后按实际字段调整「是否存在」的判定(当前暂按 ``data`` 非空兜底)。
|
||
*/
|
||
export async function fetchActionHistoryExists(actionId: string): Promise<ActionHistoryResult> {
|
||
if (!actionId) return { exists: false, known: false };
|
||
const cfg = await fetchTaskDeeplinkConfig();
|
||
if (!cfg.actionHistoryUrl) return { exists: false, known: false };
|
||
try {
|
||
const body = await requestCrudService.get(cfg.actionHistoryUrl, { actionId });
|
||
// TODO(接口就绪):按真实返回结构判定。暂按整包 body.data 非空作「存在历史」。
|
||
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
|
||
const exists = Array.isArray(data) ? data.length > 0 : data != null && data !== "";
|
||
return { exists, known: true };
|
||
} catch {
|
||
return { exists: false, known: false };
|
||
}
|
||
}
|
||
|
||
/* ----------------------------- 跳转地址 ----------------------------- */
|
||
|
||
/** 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) : "";
|
||
}
|
||
|
||
/** 「3q详情」详情页地址(目的树面板链接,新标签打开);追加 ?id=任务id。地址未配置时返回 ""。 */
|
||
export function buildDetail3qUrl(taskId: string): string {
|
||
const url = getCachedTaskDeeplinkConfig().detail3qUrl;
|
||
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 : "",
|
||
actionId: typeof parsed.actionId === "string" ? parsed.actionId : undefined,
|
||
partitionByAction: parsed.partitionByAction === true,
|
||
taskType: typeof parsed.taskType === "string" ? parsed.taskType.trim() : undefined,
|
||
goPath: readTaskDeeplinkGoPath(parsed.goPath),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 任务深链对话的 thread metadata 标签。``taskId`` 保留真实任务 id 和全局共享语义;
|
||
* ``taskRecordId + taskGoPath`` 驱动独立聊天记录索引。xdfx 的 URL ``taskId`` 实为
|
||
* 行动 id,因此其 ``taskRecordId`` 取 actionId,避免同一任务的不同行动混在一起。
|
||
* 无有效 taskId 时返回 undefined(调用方据此不打标签)。
|
||
*/
|
||
export function taskThreadMetadata(
|
||
ctx: TaskContext | null | undefined,
|
||
goPath?: TaskDeeplinkGoPath,
|
||
): Record<string, unknown> | undefined {
|
||
if (!ctx?.taskId) return undefined;
|
||
const resolvedGoPath = goPath ?? ctx.goPath;
|
||
const taskRecordId = taskRecordIdForContext(ctx);
|
||
return {
|
||
taskId: ctx.taskId,
|
||
...(ctx.partitionByAction && ctx.actionId ? { actionId: ctx.actionId } : {}),
|
||
...(resolvedGoPath
|
||
? {
|
||
taskRecordId,
|
||
taskGoPath: resolvedGoPath,
|
||
// b5-1 ?type=:同一 taskId 下不同业务类型的聊天记录分区。
|
||
...(ctx.taskType ? { taskType: ctx.taskType } : {}),
|
||
thread_type: "task_deeplink",
|
||
}
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
/** Original deep-link ``?taskId=`` used by the dedicated history partition. */
|
||
export function taskRecordIdForContext(
|
||
ctx: TaskContext | null | undefined,
|
||
): string {
|
||
if (!ctx) return "";
|
||
return ctx.partitionByAction && ctx.actionId ? ctx.actionId : ctx.taskId;
|
||
}
|
||
|
||
export function clearTaskContext(): void {
|
||
try {
|
||
sessionStorage.removeItem(TASK_CONTEXT_STORAGE_KEY);
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
}
|