feat: let canvas agent create text nodes
This commit is contained in:
parent
1e41335ebb
commit
3a2a7f2490
@ -608,6 +608,7 @@ export interface CanvasAgentAnnotationRef {
|
||||
|
||||
```ts
|
||||
export type CanvasOperationKind =
|
||||
| "create_text_node"
|
||||
| "fill_slot"
|
||||
| "insert_beside"
|
||||
| "append_revision"
|
||||
@ -740,6 +741,7 @@ Agent 首期只能请求以下操作,后端 mutation 层也只接受这些操
|
||||
| `insert_beside` | 在锚点旁边插入新结果 | 低 | 否 |
|
||||
| `append_revision` | 基于源节点追加一个新版本 | 低 | 否 |
|
||||
| `link_nodes` | 给两个节点建立参考/版本关系 | 低 | 否 |
|
||||
| `create_text_node` | 在当前视口或指定位置创建文本节点 | 低 | 否 |
|
||||
| `rename_node` | 修改图片/视频/slot/节点标题 | 低 | 否 |
|
||||
| `update_note` | 更新图片/视频节点备注并同步结构化备注标注 | 低 | 否 |
|
||||
| `update_text` | 修改选中文本节点内容 | 中 | 视情况 |
|
||||
@ -752,6 +754,7 @@ Agent 首期只能请求以下操作,后端 mutation 层也只接受这些操
|
||||
- `insert_beside`
|
||||
- `append_revision`
|
||||
- `link_nodes`
|
||||
- `create_text_node`
|
||||
- `rename_node`
|
||||
- `update_note`
|
||||
|
||||
@ -1174,6 +1177,7 @@ hasSelectedAgentSlot?: boolean;
|
||||
| “把这两张连起来” | `link_nodes` | selected nodes |
|
||||
| “把选中图片改名为白底主图 A” | `rename_node` | selected image/video/slot node |
|
||||
| “给选中图片加备注:保留主体轮廓” | `update_note` | selected image/video node |
|
||||
| “在画布上添加文字:新品上市” | `create_text_node` | current viewport center 或 targetPosition |
|
||||
| “把这段文字改成更高级” | `update_text` | selected text node |
|
||||
| “替换掉原图” | `replace_node_media` | selected image,需要确认 |
|
||||
| “把这些整理成三列” | `move_nodes` | selected nodes,建议确认 |
|
||||
|
||||
@ -16,6 +16,7 @@ const plannerDecisionSchema = z.object({
|
||||
"zoom_in",
|
||||
"zoom_out",
|
||||
"link_nodes",
|
||||
"create_text_node",
|
||||
"update_text",
|
||||
"rename_node",
|
||||
"update_note",
|
||||
@ -314,6 +315,17 @@ function hasCanvasAgentSlotCreationIntent(userText: string) {
|
||||
return resolveCanvasAgentSlotCreationIntent({ userText }).shouldCreate;
|
||||
}
|
||||
|
||||
function hasCanvasAgentTextCreationIntent(userText: string) {
|
||||
const text = userText.trim().toLowerCase();
|
||||
if (!text) return false;
|
||||
const hasCreateVerb =
|
||||
/创建|新增|新建|添加|加一段|加一句|写一段|写一句|放一段|放一句|create|add|new/.test(
|
||||
text
|
||||
) || /在(?:画布|这里|当前|旁边).*写|写到画布|写在画布|写上/.test(text);
|
||||
const hasTextWord = /文本|文字|文案|标题|标语|说明|caption|copy|text/.test(text);
|
||||
return hasCreateVerb && hasTextWord;
|
||||
}
|
||||
|
||||
function buildViewPlan(params: {
|
||||
action: PlannerDecision["action"];
|
||||
context: CanvasAgentContext;
|
||||
@ -421,6 +433,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
|
||||
/文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test(
|
||||
text
|
||||
);
|
||||
const hasCreateTextIntent = hasCanvasAgentTextCreationIntent(text);
|
||||
const hasRenameIntent = /改名|重命名|命名|标题/.test(text);
|
||||
const hasNoteIntent = /备注|note/.test(text);
|
||||
const hasAnnotationIntent = /批注|标注|备注|note|annotation/.test(text);
|
||||
@ -436,6 +449,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
|
||||
!hasViewIntent &&
|
||||
!hasLinkIntent &&
|
||||
!hasTextIntent &&
|
||||
!hasCreateTextIntent &&
|
||||
!hasRenameIntent &&
|
||||
!hasNoteIntent &&
|
||||
!hasAnnotationIntent &&
|
||||
@ -449,6 +463,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: {
|
||||
hasViewIntent ||
|
||||
hasLinkIntent ||
|
||||
hasTextIntent ||
|
||||
hasCreateTextIntent ||
|
||||
hasRenameIntent ||
|
||||
hasNoteIntent ||
|
||||
hasAnnotationIntent ||
|
||||
@ -772,6 +787,41 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (decision.action === "create_text_node") {
|
||||
const textContent = trimString(decision.textContent, 4000);
|
||||
if (!textContent) {
|
||||
return { kind: "none", reason: "create_text_node_missing_content" };
|
||||
}
|
||||
const textSize = { width: 320, height: 120 };
|
||||
const operationPlan: CanvasOperationPlan = {
|
||||
version: 1,
|
||||
operationId,
|
||||
operation: "create_text_node",
|
||||
projectId: params.projectId,
|
||||
baseCanvasRevision: params.context.canvasRevision,
|
||||
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
|
||||
targetPosition: getViewportCenteredSlotPosition({
|
||||
context: params.context,
|
||||
size: textSize,
|
||||
}),
|
||||
textContent,
|
||||
preserveOriginal: true,
|
||||
metadata: {
|
||||
messageId: params.messageId,
|
||||
chatId: params.chatId,
|
||||
planner: "model",
|
||||
prompt: params.userText,
|
||||
canvasAgentViewport: params.context.viewport,
|
||||
},
|
||||
};
|
||||
return {
|
||||
kind: "operation",
|
||||
operationPlan,
|
||||
reply: trimString(decision.reply, 280) || "已在当前画布添加文本节点。",
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
if (decision.action === "update_text") {
|
||||
const knownNodes = buildKnownNodeMap(params.context);
|
||||
const requestedIds = getDecisionNodeIds({
|
||||
@ -784,6 +834,41 @@ export function normalizeCanvasAgentModelPlannerDecision(params: {
|
||||
(nodeId) => knownNodes.get(nodeId)?.type === "text"
|
||||
);
|
||||
const textContent = trimString(decision.textContent, 4000);
|
||||
if (
|
||||
targetNodeIds.length === 0 &&
|
||||
textContent &&
|
||||
hasCanvasAgentTextCreationIntent(params.userText)
|
||||
) {
|
||||
const textSize = { width: 320, height: 120 };
|
||||
const operationPlan: CanvasOperationPlan = {
|
||||
version: 1,
|
||||
operationId: operationId.replace("canvas-model-update_text", "canvas-model-create_text_node"),
|
||||
operation: "create_text_node",
|
||||
projectId: params.projectId,
|
||||
baseCanvasRevision: params.context.canvasRevision,
|
||||
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
|
||||
targetPosition: getViewportCenteredSlotPosition({
|
||||
context: params.context,
|
||||
size: textSize,
|
||||
}),
|
||||
textContent,
|
||||
preserveOriginal: true,
|
||||
metadata: {
|
||||
messageId: params.messageId,
|
||||
chatId: params.chatId,
|
||||
planner: "model",
|
||||
prompt: params.userText,
|
||||
normalizedFromAction: "update_text",
|
||||
canvasAgentViewport: params.context.viewport,
|
||||
},
|
||||
};
|
||||
return {
|
||||
kind: "operation",
|
||||
operationPlan,
|
||||
reply: trimString(decision.reply, 280) || "已在当前画布添加文本节点。",
|
||||
reason,
|
||||
};
|
||||
}
|
||||
if (targetNodeIds.length === 0 || !textContent) {
|
||||
return { kind: "none", reason: "update_text_missing_target_or_content" };
|
||||
}
|
||||
@ -892,9 +977,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。",
|
||||
"当用户明确要求在画布中新建/添加/写入文本节点、文案、说明、标题或标语时,返回 action=create_text_node,并把最终文本写入 textContent。",
|
||||
"当用户要求给图片/视频/slot/节点改名、重命名或修改标题时,返回 action=rename_node;不要把图片节点标题改名误判为 update_text。",
|
||||
"当用户要求给图片或视频添加/更新备注时,返回 action=update_note;批注、标注、箭头、框选说明才返回 action=add_annotation。",
|
||||
"删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。",
|
||||
@ -907,10 +993,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 | rename_node | update_note | add_annotation | create_agent_slot | confirmation_required",
|
||||
"none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | create_text_node | update_text | rename_node | update_note | add_annotation | create_agent_slot | confirmation_required",
|
||||
nodeIds: "只填上下文存在的 nodeId",
|
||||
textContent:
|
||||
"update_text 时填写最终文本;rename_node 时填写新标题;update_note 时填写备注内容;add_annotation 时填写批注内容",
|
||||
"create_text_node/update_text 时填写最终文本;rename_node 时填写新标题;update_note 时填写备注内容;add_annotation 时填写批注内容",
|
||||
slotType: "create_agent_slot 时填写 image/video/ui-screen",
|
||||
slotLabel: "create_agent_slot 时可填写槽位名称",
|
||||
aspectRatio: "create_agent_slot 时可填写 1:1/16:9 等比例",
|
||||
|
||||
@ -921,6 +921,82 @@ function applyCreateAgentSlotOperation(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function getTextNodeSize(textContent: string) {
|
||||
const lines = textContent.split(/\r?\n/);
|
||||
const longestLineLength = Math.max(
|
||||
1,
|
||||
...lines.map((line) => Array.from(line).length)
|
||||
);
|
||||
return {
|
||||
width: Math.min(520, Math.max(260, longestLineLength * 10 + 48)),
|
||||
height: Math.min(280, Math.max(96, lines.length * 30 + 56)),
|
||||
};
|
||||
}
|
||||
|
||||
function applyCreateTextNodeOperation(params: {
|
||||
canvasData: CanvasDataLike;
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
plan: CanvasOperationPlan;
|
||||
}) {
|
||||
const textContent = String(params.plan.textContent || "").trim().slice(0, 4000);
|
||||
if (!textContent) {
|
||||
throw new CanvasAgentMutationError("缺少文本内容");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const size = getTextNodeSize(textContent);
|
||||
const targetPosition =
|
||||
params.plan.targetPosition &&
|
||||
Number.isFinite(Number(params.plan.targetPosition.x)) &&
|
||||
Number.isFinite(Number(params.plan.targetPosition.y))
|
||||
? {
|
||||
x: Number(params.plan.targetPosition.x),
|
||||
y: Number(params.plan.targetPosition.y),
|
||||
}
|
||||
: getCanvasViewportCenterPosition({
|
||||
canvasData: params.canvasData,
|
||||
size,
|
||||
});
|
||||
const node = {
|
||||
id: generateId(),
|
||||
type: "text",
|
||||
position: targetPosition,
|
||||
size,
|
||||
data: {
|
||||
content: textContent,
|
||||
fontSize: 18,
|
||||
color: "#111827",
|
||||
lineHeight: 1.4,
|
||||
metadata: {
|
||||
...(params.plan.metadata || {}),
|
||||
createdByAgent: true,
|
||||
canvasAgentOperationId: params.plan.operationId,
|
||||
canvasAgentOperation: params.plan.operation,
|
||||
agentCreatedAt: now,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
params.nodes.push(node);
|
||||
|
||||
return {
|
||||
canvasData: {
|
||||
...params.canvasData,
|
||||
nodes: params.nodes,
|
||||
edges: params.edges,
|
||||
selectedNodeIds: [node.id],
|
||||
},
|
||||
createdNodeIds: [node.id],
|
||||
createdNodeIdsByMediaItemId: {},
|
||||
updatedNodeIds: [],
|
||||
createdEdgeIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyOperationToCanvasData(params: {
|
||||
canvasData: CanvasDataLike;
|
||||
plan: CanvasOperationPlan;
|
||||
@ -931,6 +1007,7 @@ function applyOperationToCanvasData(params: {
|
||||
}
|
||||
if (
|
||||
params.plan.operation !== "create_agent_slot" &&
|
||||
params.plan.operation !== "create_text_node" &&
|
||||
params.plan.operation !== "insert_beside" &&
|
||||
params.plan.operation !== "append_revision" &&
|
||||
params.plan.operation !== "fill_slot" &&
|
||||
@ -1050,6 +1127,25 @@ function applyOperationToCanvasData(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (params.plan.operation === "create_text_node") {
|
||||
if (existingOperationNodes.length > 0) {
|
||||
return {
|
||||
canvasData: params.canvasData,
|
||||
createdNodeIds: existingOperationNodes.map((node) => node.id),
|
||||
createdNodeIdsByMediaItemId: {},
|
||||
updatedNodeIds: [],
|
||||
createdEdgeIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
return applyCreateTextNodeOperation({
|
||||
canvasData: params.canvasData,
|
||||
nodes,
|
||||
edges,
|
||||
plan: params.plan,
|
||||
});
|
||||
}
|
||||
|
||||
if (params.plan.operation === "rename_node") {
|
||||
const nextTitle = String(params.plan.textContent || "").trim().slice(0, 120);
|
||||
if (!nextTitle) {
|
||||
|
||||
@ -7,6 +7,7 @@ export const canvasAgentOperationPlanSchema = z
|
||||
operationId: z.string().min(1).max(160),
|
||||
operation: z.enum([
|
||||
"create_agent_slot",
|
||||
"create_text_node",
|
||||
"fill_slot",
|
||||
"insert_beside",
|
||||
"append_revision",
|
||||
@ -120,6 +121,7 @@ export const canvasAgentOperationPlanSchema = z
|
||||
.refine(
|
||||
(value) =>
|
||||
value.operation === "create_agent_slot" ||
|
||||
value.operation === "create_text_node" ||
|
||||
value.operation === "link_nodes" ||
|
||||
value.operation === "update_text" ||
|
||||
value.operation === "add_annotation" ||
|
||||
@ -153,6 +155,15 @@ export const canvasAgentOperationPlanSchema = z
|
||||
path: ["textContent"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.operation !== "create_text_node" ||
|
||||
Boolean(String(value.textContent || "").trim()),
|
||||
{
|
||||
message: "缺少文本内容",
|
||||
path: ["textContent"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.operation !== "rename_node" ||
|
||||
|
||||
@ -237,6 +237,14 @@ test.describe("canvas agent operation request schema", () => {
|
||||
textContent: "New canvas title",
|
||||
}).operationPlan.operation
|
||||
).toBe("update_text");
|
||||
|
||||
expect(
|
||||
parsePlan({
|
||||
operation: "create_text_node",
|
||||
targetPosition: { x: 120, y: 160 },
|
||||
textContent: "New canvas copy",
|
||||
}).operationPlan.operation
|
||||
).toBe("create_text_node");
|
||||
});
|
||||
|
||||
test("caps batch media at twenty items", () => {
|
||||
@ -295,6 +303,12 @@ test.describe("canvas agent operation request schema", () => {
|
||||
targetNodeIds: ["image-1"],
|
||||
})
|
||||
).toContain("operationPlan.textContent");
|
||||
|
||||
expect(
|
||||
issuePathsFor({
|
||||
operation: "create_text_node",
|
||||
})
|
||||
).toContain("operationPlan.textContent");
|
||||
});
|
||||
|
||||
test("rejects invalid media urls", () => {
|
||||
@ -599,6 +613,65 @@ test.describe("canvas agent operation mutation contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("creates text nodes from agent plans with trace metadata and idempotency", () => {
|
||||
const canvasData = {
|
||||
...getCanvasData([]),
|
||||
viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 700 },
|
||||
};
|
||||
const plan: CanvasOperationPlan = {
|
||||
version: 1,
|
||||
operationId: "op-create-text-node",
|
||||
operation: "create_text_node",
|
||||
projectId,
|
||||
textContent: "618 限时优惠\n新品主推",
|
||||
metadata: {
|
||||
messageId: "assistant-message-text",
|
||||
chatId: "chat-1",
|
||||
prompt: "在画布上添加一段促销文案",
|
||||
},
|
||||
};
|
||||
|
||||
const first = applyCanvasOperationPlanToCanvasData({
|
||||
canvasData,
|
||||
plan,
|
||||
});
|
||||
const createdTextId = first.createdNodeIds[0];
|
||||
const createdText = getResultNodes(first.canvasData).find(
|
||||
(node) => node.id === createdTextId
|
||||
);
|
||||
|
||||
expect(createdText).toMatchObject({
|
||||
type: "text",
|
||||
position: { x: 570, y: 392 },
|
||||
data: {
|
||||
content: "618 限时优惠\n新品主推",
|
||||
fontSize: 18,
|
||||
color: "#111827",
|
||||
metadata: {
|
||||
createdByAgent: true,
|
||||
canvasAgentOperationId: "op-create-text-node",
|
||||
canvasAgentOperation: "create_text_node",
|
||||
messageId: "assistant-message-text",
|
||||
chatId: "chat-1",
|
||||
prompt: "在画布上添加一段促销文案",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect((first.canvasData as { selectedNodeIds?: string[] }).selectedNodeIds).toEqual([
|
||||
createdTextId,
|
||||
]);
|
||||
|
||||
const retry = applyCanvasOperationPlanToCanvasData({
|
||||
canvasData: first.canvasData,
|
||||
plan,
|
||||
});
|
||||
|
||||
expect(retry.createdNodeIds).toEqual(first.createdNodeIds);
|
||||
expect(getResultNodes(retry.canvasData)).toHaveLength(
|
||||
getResultNodes(first.canvasData).length
|
||||
);
|
||||
});
|
||||
|
||||
test("fills an agent slot and returns media item node mappings", () => {
|
||||
const slot = buildAgentSlotNode();
|
||||
const result = applyCanvasOperationPlanToCanvasData({
|
||||
|
||||
@ -781,6 +781,73 @@ test.describe("canvas chat pure helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes model-planned text creation into new canvas text nodes", () => {
|
||||
const context: CanvasAgentContext = {
|
||||
...plannerContext,
|
||||
selectedNodeIds: [],
|
||||
selectedNodes: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
|
||||
focusPolicy: "auto",
|
||||
};
|
||||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||||
decision: {
|
||||
action: "create_text_node",
|
||||
textContent: "618 限时优惠,新品主推",
|
||||
},
|
||||
context,
|
||||
projectId: plannerContext.projectId || "",
|
||||
messageId: "message-1",
|
||||
chatId: "chat-1",
|
||||
userText: "在画布上添加一段促销文案:618 限时优惠,新品主推",
|
||||
});
|
||||
|
||||
expect(outcome.kind).toBe("operation");
|
||||
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
|
||||
operation: "create_text_node",
|
||||
targetPosition: { x: 440, y: 340 },
|
||||
textContent: "618 限时优惠,新品主推",
|
||||
preserveOriginal: true,
|
||||
metadata: {
|
||||
messageId: "message-1",
|
||||
chatId: "chat-1",
|
||||
planner: "model",
|
||||
canvasAgentViewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("turns new-text update decisions into text creation when no text node is targeted", () => {
|
||||
const context: CanvasAgentContext = {
|
||||
...plannerContext,
|
||||
selectedNodeIds: ["image-1"],
|
||||
selectedNodes: [plannerContext.selectedNodes[0]],
|
||||
viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 700 },
|
||||
focusPolicy: "selected",
|
||||
};
|
||||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||||
decision: {
|
||||
action: "update_text",
|
||||
nodeIds: ["image-1"],
|
||||
textContent: "新品上市",
|
||||
},
|
||||
context,
|
||||
projectId: plannerContext.projectId || "",
|
||||
messageId: "message-2",
|
||||
chatId: "chat-1",
|
||||
userText: "在画布上添加文字:新品上市",
|
||||
});
|
||||
|
||||
expect(outcome.kind).toBe("operation");
|
||||
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
|
||||
operation: "create_text_node",
|
||||
targetPosition: { x: 540, y: 390 },
|
||||
textContent: "新品上市",
|
||||
metadata: {
|
||||
normalizedFromAction: "update_text",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes model-planned node renames without requiring text nodes", () => {
|
||||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||||
decision: {
|
||||
|
||||
@ -111,6 +111,7 @@ export interface CanvasAgentContext {
|
||||
|
||||
export type CanvasOperationKind =
|
||||
| "create_agent_slot"
|
||||
| "create_text_node"
|
||||
| "fill_slot"
|
||||
| "insert_beside"
|
||||
| "append_revision"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user