feat: let canvas agent rename nodes

This commit is contained in:
134 2026-06-23 16:27:06 +08:00
parent 763696e8d0
commit e319b16f48
7 changed files with 200 additions and 3 deletions

View File

@ -615,6 +615,7 @@ export type CanvasOperationKind =
| "add_annotation"
| "link_nodes"
| "move_nodes"
| "rename_node"
| "update_text";
export interface CanvasMediaItem {
@ -738,6 +739,7 @@ Agent 首期只能请求以下操作,后端 mutation 层也只接受这些操
| `insert_beside` | 在锚点旁边插入新结果 | 低 | 否 |
| `append_revision` | 基于源节点追加一个新版本 | 低 | 否 |
| `link_nodes` | 给两个节点建立参考/版本关系 | 低 | 否 |
| `rename_node` | 修改图片/视频/slot/节点标题 | 低 | 否 |
| `update_text` | 修改选中文本节点内容 | 中 | 视情况 |
| `replace_node_media` | 替换已有图片/视频内容 | 高 | 是 |
| `move_nodes` | 批量移动或整理节点 | 中 | 视情况 |
@ -748,6 +750,7 @@ Agent 首期只能请求以下操作,后端 mutation 层也只接受这些操
- `insert_beside`
- `append_revision`
- `link_nodes`
- `rename_node`
这样可以先把“对话框驱动画布”跑通,同时把误操作风险降到最低。
@ -1062,6 +1065,7 @@ autosave 冲突要求:
- `fill_slot`,仅限空 slot 或 append-version slot
- `append_revision`
- `link_nodes`
- `rename_node`
首期 revision 不一致时必须拒绝或要求确认的操作:
@ -1164,6 +1168,7 @@ hasSelectedAgentSlot?: boolean;
| “再出三版放右边” | `insert_beside` | selected image 或 latest result |
| “按批注修改” | `append_revision` | selected image + annotations |
| “把这两张连起来” | `link_nodes` | selected nodes |
| “把选中图片改名为白底主图 A” | `rename_node` | selected image/video/slot node |
| “把这段文字改成更高级” | `update_text` | selected text node |
| “替换掉原图” | `replace_node_media` | selected image,需要确认 |
| “把这些整理成三列” | `move_nodes` | selected nodes,建议确认 |

View File

@ -17,6 +17,7 @@ const plannerDecisionSchema = z.object({
"zoom_out",
"link_nodes",
"update_text",
"rename_node",
"add_annotation",
"create_agent_slot",
"confirmation_required",
@ -419,6 +420,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
/文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test(
text
);
const hasRenameIntent = /改名|重命名|命名|标题/.test(text);
const hasAnnotationIntent = /批注|标注|备注|note|annotation/.test(text);
const hasSlotIntent = hasCanvasAgentSlotCreationIntent(text);
const hasHighRiskIntent = Boolean(inferHighRiskOperation(text));
@ -432,6 +434,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
!hasViewIntent &&
!hasLinkIntent &&
!hasTextIntent &&
!hasRenameIntent &&
!hasAnnotationIntent &&
!hasSlotIntent &&
!hasHighRiskIntent
@ -443,6 +446,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
hasViewIntent ||
hasLinkIntent ||
hasTextIntent ||
hasRenameIntent ||
hasAnnotationIntent ||
hasSlotIntent ||
hasHighRiskIntent
@ -763,6 +767,50 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
};
}
if (decision.action === "rename_node") {
const targetNodeIds = getDecisionNodeIds({
decision,
context: params.context,
allowSelectedFallback: true,
maxItems: 10,
});
const title = trimString(decision.textContent, 120);
if (targetNodeIds.length === 0 || !title) {
return { kind: "none", reason: "rename_node_missing_target_or_title" };
}
const operationPlan: CanvasOperationPlan = {
version: 1,
operationId,
operation: "rename_node",
projectId: params.projectId,
baseCanvasRevision: params.context.canvasRevision,
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
targetNodeIds,
textContent: title,
preserveOriginal: true,
metadata: {
messageId: params.messageId,
chatId: params.chatId,
planner: "model",
prompt: params.userText,
},
};
return {
kind: "operation",
operationPlan,
viewPlan: buildViewPlan({
action: "focus_nodes",
context: params.context,
projectId: params.projectId,
operationId,
nodeIds: targetNodeIds,
reason: "focus_model_renamed_nodes",
}) || undefined,
reply: trimString(decision.reply, 280) || "已更新选中节点标题。",
reason,
};
}
return { kind: "none", reason: "unsupported_model_planner_action" };
}
@ -791,9 +839,10 @@ export async function planCanvasAgentOperationWithModel(params: {
abortSignal: params.abortSignal || AbortSignal.timeout(18_000),
system: [
"你是无限画布 Agent 的受控操作规划器。",
"你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点、给图片或视频节点添加批注、创建空 Agent 槽位。",
"你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点、修改节点标题、给图片或视频节点添加批注、创建空 Agent 槽位。",
"图片/视频生成、换背景、抠图、高清、扩图、重绘等媒体任务必须返回 action=none,交给其他执行器。",
"当用户明确要求新建/添加空图片槽位、视频槽位、UI槽位、空框或占位时,可以返回 action=create_agent_slot;如果同时要求生成或填充媒体,返回 action=none。",
"当用户要求给图片/视频/slot/节点改名、重命名或修改标题时,返回 action=rename_node;不要把图片节点标题改名误判为 update_text。",
"删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。",
"只能使用上下文中出现的 nodeId。不要编造节点 ID。",
"如果用户只是聊天或目标不明确,返回 action=none。",
@ -804,9 +853,10 @@ export async function planCanvasAgentOperationWithModel(params: {
context: buildContextSummary(params.context),
outputContract: {
action:
"none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | update_text | add_annotation | create_agent_slot | confirmation_required",
"none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | update_text | rename_node | add_annotation | create_agent_slot | confirmation_required",
nodeIds: "只填上下文存在的 nodeId",
textContent: "update_text 时填写最终文本;add_annotation 时填写批注内容",
textContent:
"update_text 时填写最终文本;rename_node 时填写新标题;add_annotation 时填写批注内容",
slotType: "create_agent_slot 时填写 image/video/ui-screen",
slotLabel: "create_agent_slot 时可填写槽位名称",
aspectRatio: "create_agent_slot 时可填写 1:1/16:9 等比例",

View File

@ -938,6 +938,7 @@ function applyOperationToCanvasData(params: {
params.plan.operation !== "move_nodes" &&
params.plan.operation !== "replace_node_media" &&
params.plan.operation !== "delete_nodes" &&
params.plan.operation !== "rename_node" &&
params.plan.operation !== "update_text"
) {
throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作");
@ -1047,6 +1048,63 @@ function applyOperationToCanvasData(params: {
});
}
if (params.plan.operation === "rename_node") {
const nextTitle = String(params.plan.textContent || "").trim().slice(0, 120);
if (!nextTitle) {
throw new CanvasAgentMutationError("缺少节点标题");
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要改名的节点");
}
const now = new Date().toISOString();
const updatedNodeIds: string[] = [];
targetNodeIds.forEach((nodeId) => {
const node = findNode(nodes, nodeId);
if (!node) {
throw new CanvasAgentMutationError(`改名节点不存在:${nodeId}`);
}
const data = (node.data || {}) as Record<string, any>;
const previousTitle =
typeof data.title === "string"
? data.title
: typeof data.label === "string"
? data.label
: undefined;
node.data = {
...data,
title: nextTitle,
label: node.type === "agent-slot" ? nextTitle : data.label,
metadata: {
...(data.metadata || {}),
renamedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
previousTitle,
agentRenamedAt: now,
},
};
node.updatedAt = now;
updatedNodeIds.push(node.id);
});
return {
canvasData: {
...params.canvasData,
nodes,
edges,
selectedNodeIds: updatedNodeIds,
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds,
createdEdgeIds: [],
};
}
if (params.plan.operation === "update_text") {
const textContent = String(params.plan.textContent || "").trim();
if (!textContent) {

View File

@ -15,6 +15,7 @@ export const canvasAgentOperationPlanSchema = z
"link_nodes",
"move_nodes",
"delete_nodes",
"rename_node",
"update_text",
]),
projectId: z.string().uuid().optional(),
@ -123,6 +124,7 @@ export const canvasAgentOperationPlanSchema = z
value.operation === "add_annotation" ||
value.operation === "move_nodes" ||
value.operation === "delete_nodes" ||
value.operation === "rename_node" ||
value.media ||
(value.mediaItems?.length ?? 0) > 0,
{
@ -148,6 +150,15 @@ export const canvasAgentOperationPlanSchema = z
message: "缺少文本内容",
path: ["textContent"],
}
)
.refine(
(value) =>
value.operation !== "rename_node" ||
Boolean(String(value.textContent || "").trim()),
{
message: "缺少节点标题",
path: ["textContent"],
}
);
export const canvasAgentOperationRequestSchema = z.object({

View File

@ -281,6 +281,13 @@ test.describe("canvas agent operation request schema", () => {
targetNodeIds: ["text-1"],
})
).toContain("operationPlan.textContent");
expect(
issuePathsFor({
operation: "rename_node",
targetNodeIds: ["image-1"],
})
).toContain("operationPlan.textContent");
});
test("rejects invalid media urls", () => {
@ -752,6 +759,44 @@ test.describe("canvas agent operation mutation contract", () => {
expect(retryNode.data.metadata.annotations).toHaveLength(1);
});
test("renames canvas nodes with trace metadata", () => {
const target = buildImageNode("image-rename");
target.data.title = "旧主图";
const result = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([target]),
plan: {
version: 1,
operationId: "op-rename-node",
operation: "rename_node",
projectId,
targetNodeIds: ["image-rename"],
textContent: "白底主图 A",
metadata: {
messageId: "assistant-message-rename",
chatId: "chat-1",
},
},
});
const renamedNode = getResultNodes(result.canvasData).find(
(node) => node.id === "image-rename"
);
expect(result.createdNodeIds).toEqual([]);
expect(result.updatedNodeIds).toEqual(["image-rename"]);
expect(renamedNode).toMatchObject({
data: {
title: "白底主图 A",
metadata: {
renamedByAgent: true,
canvasAgentOperationId: "op-rename-node",
canvasAgentOperation: "rename_node",
previousTitle: "旧主图",
},
},
});
});
test("keeps batch media writes idempotent by operation id", () => {
const anchor = buildImageNode("image-anchor");
const plan = {

View File

@ -734,6 +734,33 @@ test.describe("canvas chat pure helpers", () => {
});
});
test("normalizes model-planned node renames without requiring text nodes", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "rename_node",
nodeIds: ["image-1"],
textContent: "白底主图 A",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中图片标题改成白底主图 A",
});
expect(outcome.kind).toBe("operation");
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
operation: "rename_node",
targetNodeIds: ["image-1"],
textContent: "白底主图 A",
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "model",
},
});
});
test("normalizes model-planned annotations to selected media nodes", () => {
const outcome = normalizeCanvasAgentModelPlannerDecision({
decision: {

View File

@ -119,6 +119,7 @@ export type CanvasOperationKind =
| "link_nodes"
| "move_nodes"
| "delete_nodes"
| "rename_node"
| "update_text";
export interface CanvasMediaItem {