feat: 任务目的树表格化展示 + 切换会话停止老对话服务端 run

- task-purpose: normalizeNode 容错(未知 *Dto 兜底/文本字段改名兜底);新增 flattenPurposeTree 拍平目的树
- TaskPurposePanel: 目的树改表格展示(列数自适应),支持弹框放大查看
- threads/hooks: 新增 stopThreadRun,真正取消服务端 run(非仅断开本地 SSE)
- AgentChatPage: taskGate 下切换/卸载会话时停掉老对话服务端 run
- TaskFxWorkspace: 操作按钮(编辑/下一步/清除)改右下角常驻红色浮层

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-17 16:42:05 +08:00
1 parent ba0ec20532
commit a2bb8c80a2
5 files changed
+273 -106

No files matched your search

+54 -12
View File
@@ -48,28 +48,70 @@ const DTO_FIELD_MAP: Record<string, { label: string; textKey: string }> = {
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>;
// 找出本节点携带的 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 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;
}
/** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */
function bodyToTree(body: unknown): PurposeTreeNode[] {
const data =