feat: route canvas agent text updates

This commit is contained in:
134 2026-06-23 23:04:43 +08:00
parent 67dbeeab1a
commit 8a2483ad7d
4 changed files with 161 additions and 1 deletions

View File

@ -12,6 +12,7 @@ type ExplicitOperationKind =
| "create_agent_slot"
| "create_text_node"
| "rename_node"
| "update_text"
| "update_note";
export type CanvasAgentExplicitOperationPlan = {
@ -199,6 +200,60 @@ function buildCreateTextOperation(params: {
};
}
function hasImageTextEditIntent(userText: string) {
return /(?:图片|图像|照片|主图|海报|画面|图中|图里|图片里|图片上|海报上).*(?:文字|文本|文案|字).*(?:改|换|替换|编辑|修改|去掉|删除)|(?:文字|文本|文案|字).*(?:图片|图像|照片|主图|海报|画面|图中|图里|图片里|图片上|海报上).*(?:改|换|替换|编辑|修改|去掉|删除)/.test(
userText
);
}
function buildUpdateTextOperation(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
requestedNodeIds?: string[];
}): CanvasAgentExplicitOperationPlan | null {
const text = params.userText.trim();
const hasUpdateTextIntent =
/(?:选中|当前|这个|这段|该).*(?:文本|文字|文案|标语|说明|copy|text).*(?:改成|改为|修改为|更新为|设为|设置为|换成)|(?:文本|文字|文案|标语|说明|copy|text).*(?:改成|改为|修改为|更新为|设为|设置为|换成)/i.test(
text
);
if (!hasUpdateTextIntent || hasMediaMutationIntent(text)) return null;
if (hasImageTextEditIntent(text)) return null;
const textContent = extractContentFromPatterns(
text,
[
/(?:改成|改为|修改为|更新为|设为|设置为|换成)\s*[::]?\s*([\s\S]+)$/i,
/(?:文本|文字|文案|标语|说明|copy|text)[^::]*(?:改成|改为|修改为|更新为|设为|设置为|换成)\s*[::]?\s*([\s\S]+)$/i,
/[::]\s*([\s\S]+)$/i,
],
4000
);
if (!textContent) return null;
const targetNodeIds = getTargetNodeIds({
context: params.context,
requestedNodeIds: params.requestedNodeIds,
allowedTypes: ["text"],
}).slice(0, 10);
if (targetNodeIds.length === 0) return null;
return {
operationPlan: {
...buildPlanBase({ ...params, operation: "update_text" }),
targetNodeIds,
textContent,
},
reply: "已更新选中文本内容。",
progressTitle: "正在更新画布文本",
progressMessage: "我会只修改选中文本节点的内容,不移动画布节点。",
completedTitle: "画布文本已更新",
failedTitle: "画布文本更新失败",
};
}
function getSlotTypeLabel(slotType: "image" | "video" | "ui-screen") {
if (slotType === "video") return "视频";
if (slotType === "ui-screen") return "UI";
@ -407,6 +462,7 @@ export function buildCanvasAgentExplicitOperationPlan(params: {
return (
buildCreateAgentSlotOperation(commonParams) ||
buildCreateTextOperation(commonParams) ||
buildUpdateTextOperation(commonParams) ||
buildRenameOperation(commonParams) ||
buildUpdateNoteOperation(commonParams) ||
buildAddAnnotationOperation(commonParams)

View File

@ -1132,7 +1132,10 @@ function applyOperationToCanvasData(params: {
if (
existingOperationNodes.length > 0 &&
(params.plan.operation === "replace_node_media" ||
params.plan.operation === "move_nodes")
params.plan.operation === "move_nodes" ||
params.plan.operation === "rename_node" ||
params.plan.operation === "update_note" ||
params.plan.operation === "update_text")
) {
return {
canvasData: params.canvasData,
@ -1385,6 +1388,7 @@ function applyOperationToCanvasData(params: {
content: textContent,
metadata: {
...(data.metadata || {}),
...(params.plan.metadata || {}),
updatedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,

View File

@ -83,6 +83,23 @@ function buildImageNode(id = "image-1") {
};
}
function buildTextNode(id = "text-1") {
return {
id,
type: "text",
position: { x: 160, y: 180 },
size: { width: 280, height: 120 },
data: {
content: "旧文案",
fontSize: 18,
color: "#111827",
},
connections: [],
createdAt: "2026-06-22T00:00:00.000Z",
updatedAt: "2026-06-22T00:00:00.000Z",
};
}
function buildAgentSlotNode(id = "slot-1", slotType: "image" | "video" = "image") {
return {
id,
@ -751,6 +768,56 @@ test.describe("canvas agent operation mutation contract", () => {
);
});
test("updates selected text nodes with trace metadata and idempotency", () => {
const plan: CanvasOperationPlan = {
version: 1,
operationId: "op-update-text-node",
operation: "update_text",
projectId,
targetNodeIds: ["text-update"],
textContent: "轻盈强吸力,家务更省心",
metadata: {
messageId: "assistant-message-update-text",
chatId: "chat-1",
},
};
const first = applyCanvasOperationPlanToCanvasData({
canvasData: getCanvasData([buildTextNode("text-update")]),
plan,
});
const updatedText = getResultNodes(first.canvasData).find(
(node) => node.id === "text-update"
);
expect(first.createdNodeIds).toEqual([]);
expect(first.updatedNodeIds).toEqual(["text-update"]);
expect(updatedText).toMatchObject({
data: {
content: "轻盈强吸力,家务更省心",
metadata: {
updatedByAgent: true,
canvasAgentOperationId: "op-update-text-node",
canvasAgentOperation: "update_text",
previousContent: "旧文案",
messageId: "assistant-message-update-text",
chatId: "chat-1",
},
},
});
const retry = applyCanvasOperationPlanToCanvasData({
canvasData: first.canvasData,
plan,
});
const retryText = getResultNodes(retry.canvasData).find(
(node) => node.id === "text-update"
);
expect(retry.updatedNodeIds).toEqual(["text-update"]);
expect(retryText.data.metadata.previousContent).toBe("旧文案");
});
test("fills an agent slot and returns media item node mappings", () => {
const slot = buildAgentSlotNode();
const result = applyCanvasOperationPlanToCanvasData({

View File

@ -1432,6 +1432,29 @@ test.describe("canvas chat pure helpers", () => {
});
});
test("builds deterministic text update plans for selected text nodes", () => {
const outcome = buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中文案改成:轻盈强吸力,家务更省心",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "update_text",
targetNodeIds: ["text-1"],
textContent: "轻盈强吸力,家务更省心",
preserveOriginal: true,
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
},
});
expect(outcome?.reply).toContain("文本");
});
test("builds deterministic agent slot creation plans from explicit canvas prompts", () => {
const context: CanvasAgentContext = {
...plannerContext,
@ -1601,6 +1624,16 @@ test.describe("canvas chat pure helpers", () => {
userText: "按这张图上的批注修改",
})
).toBeNull();
expect(
buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把图片里的文字改成新品上市",
})
).toBeNull();
});
test("normalizes model-planned annotations to selected media nodes", () => {