822 lines
26 KiB
TypeScript
822 lines
26 KiB
TypeScript
import { generateObject, type LanguageModel } from "ai";
|
||
import { z } from "zod";
|
||
import type {
|
||
CanvasAgentContext,
|
||
CanvasOperationPlan,
|
||
CanvasViewOperationPlan,
|
||
} from "@/types/canvas/agent-protocol";
|
||
|
||
const plannerDecisionSchema = z.object({
|
||
action: z.enum([
|
||
"none",
|
||
"focus_nodes",
|
||
"fit_all",
|
||
"zoom_in",
|
||
"zoom_out",
|
||
"link_nodes",
|
||
"update_text",
|
||
"add_annotation",
|
||
"create_agent_slot",
|
||
"confirmation_required",
|
||
]),
|
||
nodeIds: z.array(z.string().min(1).max(160)).max(20).optional(),
|
||
textContent: z.string().max(4000).optional(),
|
||
slotType: z.enum(["image", "video", "ui-screen"]).optional(),
|
||
slotLabel: z.string().max(120).optional(),
|
||
aspectRatio: z.string().max(32).optional(),
|
||
riskOperation: z
|
||
.enum([
|
||
"replace_node_media",
|
||
"delete_nodes",
|
||
"move_nodes",
|
||
"overwrite_slot",
|
||
"batch_move",
|
||
"other",
|
||
])
|
||
.optional(),
|
||
reply: z.string().max(280).optional(),
|
||
reason: z.string().max(280).optional(),
|
||
});
|
||
|
||
type PlannerDecision = z.infer<typeof plannerDecisionSchema>;
|
||
|
||
export type CanvasAgentSlotCreationIntent = {
|
||
shouldCreate: boolean;
|
||
combinedWithMediaExecution: boolean;
|
||
slotType: "image" | "video" | "ui-screen";
|
||
label: string;
|
||
aspectRatio: string;
|
||
};
|
||
|
||
export type CanvasAgentModelPlannerOutcome =
|
||
| {
|
||
kind: "view";
|
||
viewPlan: CanvasViewOperationPlan;
|
||
reply: string;
|
||
reason?: string;
|
||
}
|
||
| {
|
||
kind: "operation";
|
||
operationPlan: CanvasOperationPlan;
|
||
viewPlan?: CanvasViewOperationPlan;
|
||
reply: string;
|
||
reason?: string;
|
||
}
|
||
| {
|
||
kind: "confirmation_required";
|
||
riskOperation: string;
|
||
targetNodeIds: string[];
|
||
operationPlan?: CanvasOperationPlan;
|
||
viewPlan?: CanvasViewOperationPlan;
|
||
reply: string;
|
||
reason?: string;
|
||
}
|
||
| {
|
||
kind: "none";
|
||
reason?: string;
|
||
};
|
||
|
||
function trimString(value: unknown, maxLength: number) {
|
||
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
|
||
}
|
||
|
||
function uniqueStrings(values: Array<string | undefined | null>, maxItems = 20) {
|
||
return Array.from(
|
||
new Set(values.map((value) => trimString(value, 160)).filter(Boolean))
|
||
).slice(0, maxItems);
|
||
}
|
||
|
||
function buildKnownNodeMap(context: CanvasAgentContext) {
|
||
const nodeMap = new Map<string, { nodeId: string; type: string; isSelected?: boolean }>();
|
||
[...context.candidateNodes, ...context.selectedNodes].forEach((node) => {
|
||
nodeMap.set(node.nodeId, {
|
||
nodeId: node.nodeId,
|
||
type: node.type,
|
||
isSelected: node.isSelected,
|
||
});
|
||
});
|
||
context.slots.forEach((slot) => {
|
||
nodeMap.set(slot.nodeId, {
|
||
nodeId: slot.nodeId,
|
||
type: "agent-slot",
|
||
isSelected: slot.isSelected,
|
||
});
|
||
});
|
||
return nodeMap;
|
||
}
|
||
|
||
function getSelectedKnownNodeIds(context: CanvasAgentContext, maxItems = 20) {
|
||
const selectedFromRefs = [
|
||
...context.selectedNodes.map((node) => node.nodeId),
|
||
...context.slots
|
||
.filter((slot) => slot.isSelected || context.selectedNodeIds.includes(slot.nodeId))
|
||
.map((slot) => slot.nodeId),
|
||
];
|
||
return uniqueStrings([...context.selectedNodeIds, ...selectedFromRefs], maxItems);
|
||
}
|
||
|
||
function getDecisionNodeIds(params: {
|
||
decision: PlannerDecision;
|
||
context: CanvasAgentContext;
|
||
allowSelectedFallback: boolean;
|
||
maxItems?: number;
|
||
}) {
|
||
const knownNodes = buildKnownNodeMap(params.context);
|
||
const requestedIds = uniqueStrings(params.decision.nodeIds || [], params.maxItems || 20).filter(
|
||
(nodeId) => knownNodes.has(nodeId)
|
||
);
|
||
if (requestedIds.length > 0 || !params.allowSelectedFallback) {
|
||
return requestedIds;
|
||
}
|
||
return getSelectedKnownNodeIds(params.context, params.maxItems || 20).filter((nodeId) =>
|
||
knownNodes.has(nodeId)
|
||
);
|
||
}
|
||
|
||
function inferHighRiskOperation(userText: string) {
|
||
const text = userText.toLowerCase();
|
||
if (/删除|删掉|移除/.test(text)) return "delete_nodes";
|
||
if (/覆盖|替换当前|替换原图|直接替换|覆盖原图/.test(text)) {
|
||
return "replace_node_media";
|
||
}
|
||
if (/批量移动|整体移动|移动到|移到|整理|排列|排版/.test(text)) {
|
||
return "move_nodes";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function defaultConfirmationReply(riskOperation: string) {
|
||
if (riskOperation === "delete_nodes") {
|
||
return "这个动作会删除画布节点,需要先确认后才能执行。";
|
||
}
|
||
if (riskOperation === "replace_node_media") {
|
||
return "这个动作会覆盖或替换现有图片,需要先确认后才能执行。";
|
||
}
|
||
if (riskOperation === "move_nodes" || riskOperation === "batch_move") {
|
||
return "这个动作会移动或重排画布节点,需要先确认后才能执行。";
|
||
}
|
||
return "这个画布动作风险较高,需要先确认后才能执行。";
|
||
}
|
||
|
||
function inferMoveLayoutStrategy(userText: string): "row" | "column" | "grid" {
|
||
const text = userText.toLowerCase();
|
||
if (/网格|宫格|矩阵|两列|三列|多列|grid/.test(text)) return "grid";
|
||
if (/竖|纵|列|上下|从上到下|column/.test(text)) return "column";
|
||
return "row";
|
||
}
|
||
|
||
function hasCombinedMediaExecutionIntent(userText: string) {
|
||
return /并生成|然后生成|填充|出图|做一版|再出|换背景|抠图|去水印|高清|扩图|重绘|变体|图层|多角度|洗图/.test(
|
||
userText.toLowerCase()
|
||
);
|
||
}
|
||
|
||
function inferAgentSlotType(
|
||
userText: string,
|
||
modelSlotType?: "image" | "video" | "ui-screen"
|
||
): "image" | "video" | "ui-screen" {
|
||
if (modelSlotType === "video" || modelSlotType === "ui-screen") {
|
||
return modelSlotType;
|
||
}
|
||
const text = userText.toLowerCase();
|
||
if (/视频|video/.test(text)) return "video";
|
||
if (/ui|界面|页面|画面|screen/.test(text)) return "ui-screen";
|
||
return "image";
|
||
}
|
||
|
||
function getAgentSlotDefaults(slotType: "image" | "video" | "ui-screen") {
|
||
if (slotType === "video") {
|
||
return {
|
||
label: "视频槽位",
|
||
aspectRatio: "16:9",
|
||
size: { width: 360, height: 220 },
|
||
};
|
||
}
|
||
if (slotType === "ui-screen") {
|
||
return {
|
||
label: "UI 槽位",
|
||
aspectRatio: "16:9",
|
||
size: { width: 360, height: 260 },
|
||
};
|
||
}
|
||
return {
|
||
label: "图片槽位",
|
||
aspectRatio: "1:1",
|
||
size: { width: 320, height: 320 },
|
||
};
|
||
}
|
||
|
||
function inferAspectRatioFromText(userText: string) {
|
||
const match = userText.match(/\b(?:1:1|4:3|3:4|16:9|9:16)\b/);
|
||
return match?.[0] || "";
|
||
}
|
||
|
||
export function resolveCanvasAgentSlotCreationIntent(params: {
|
||
userText: string;
|
||
slotType?: "image" | "video" | "ui-screen";
|
||
slotLabel?: string;
|
||
aspectRatio?: string;
|
||
}): CanvasAgentSlotCreationIntent {
|
||
const text = params.userText.trim();
|
||
const lowerText = text.toLowerCase();
|
||
const hasCreationVerb =
|
||
/创建|新增|新建|添加|放一个|加一个|建一个|开一个|create|add|new/.test(
|
||
lowerText
|
||
);
|
||
const hasSlotWord = /slot|槽位|空框|占位|holder/.test(lowerText);
|
||
const slotType = inferAgentSlotType(text, params.slotType);
|
||
const defaults = getAgentSlotDefaults(slotType);
|
||
return {
|
||
shouldCreate: hasCreationVerb && hasSlotWord,
|
||
combinedWithMediaExecution: hasCombinedMediaExecutionIntent(text),
|
||
slotType,
|
||
label: trimString(params.slotLabel, 120) || defaults.label,
|
||
aspectRatio:
|
||
trimString(params.aspectRatio, 32) ||
|
||
inferAspectRatioFromText(text) ||
|
||
defaults.aspectRatio,
|
||
};
|
||
}
|
||
|
||
function getViewportCenteredSlotPosition(params: {
|
||
context: CanvasAgentContext;
|
||
size: { width: number; height: number };
|
||
}) {
|
||
const viewport = params.context.viewport || { x: 0, y: 0, zoom: 1 };
|
||
const zoom = Math.max(0.1, Number(viewport.zoom || 1));
|
||
const viewportWidth = Math.max(320, Number(viewport.width || 1200));
|
||
const viewportHeight = Math.max(320, Number(viewport.height || 800));
|
||
return {
|
||
x: (viewportWidth / 2 - Number(viewport.x || 0)) / zoom - params.size.width / 2,
|
||
y: (viewportHeight / 2 - Number(viewport.y || 0)) / zoom - params.size.height / 2,
|
||
};
|
||
}
|
||
|
||
export function buildCanvasAgentSlotCreationPlan(params: {
|
||
context: CanvasAgentContext;
|
||
projectId: string;
|
||
messageId: string;
|
||
chatId: string;
|
||
userText: string;
|
||
operationId?: string;
|
||
slotType?: "image" | "video" | "ui-screen";
|
||
slotLabel?: string;
|
||
aspectRatio?: string;
|
||
}): CanvasOperationPlan | null {
|
||
const intent = resolveCanvasAgentSlotCreationIntent({
|
||
userText: params.userText,
|
||
slotType: params.slotType,
|
||
slotLabel: params.slotLabel,
|
||
aspectRatio: params.aspectRatio,
|
||
});
|
||
if (!intent.shouldCreate) return null;
|
||
|
||
const defaults = getAgentSlotDefaults(intent.slotType);
|
||
return {
|
||
version: 1,
|
||
operationId: params.operationId || `${params.messageId}:canvas-create-agent-slot`,
|
||
operation: "create_agent_slot",
|
||
projectId: params.projectId,
|
||
baseCanvasRevision: params.context.canvasRevision,
|
||
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
|
||
targetPosition: getViewportCenteredSlotPosition({
|
||
context: params.context,
|
||
size: defaults.size,
|
||
}),
|
||
slot: {
|
||
slotType: intent.slotType,
|
||
label: intent.label,
|
||
aspectRatio: intent.aspectRatio,
|
||
fillPolicy: "replace-empty",
|
||
acceptedSources: ["text", "image", "selection", "annotation"],
|
||
},
|
||
preserveOriginal: true,
|
||
metadata: {
|
||
messageId: params.messageId,
|
||
chatId: params.chatId,
|
||
planner: "model",
|
||
prompt: params.userText,
|
||
},
|
||
};
|
||
}
|
||
|
||
function hasCanvasAgentSlotCreationIntent(userText: string) {
|
||
return resolveCanvasAgentSlotCreationIntent({ userText }).shouldCreate;
|
||
}
|
||
|
||
function buildViewPlan(params: {
|
||
action: PlannerDecision["action"];
|
||
context: CanvasAgentContext;
|
||
projectId: string;
|
||
operationId: string;
|
||
nodeIds?: string[];
|
||
reason?: string;
|
||
}): CanvasViewOperationPlan | null {
|
||
if (params.action === "fit_all") {
|
||
return {
|
||
version: 1,
|
||
viewOperationId: `${params.operationId}:view`,
|
||
operation: "fit_all",
|
||
projectId: params.projectId,
|
||
padding: 120,
|
||
persist: false,
|
||
reason: params.reason || "model_planned_fit_all",
|
||
};
|
||
}
|
||
if (params.action === "zoom_in" || params.action === "zoom_out") {
|
||
return {
|
||
version: 1,
|
||
viewOperationId: `${params.operationId}:view`,
|
||
operation: params.action,
|
||
projectId: params.projectId,
|
||
persist: false,
|
||
reason: params.reason || `model_planned_${params.action}`,
|
||
};
|
||
}
|
||
|
||
const nodeIds =
|
||
params.nodeIds && params.nodeIds.length > 0
|
||
? params.nodeIds
|
||
: getSelectedKnownNodeIds(params.context, 10);
|
||
if (nodeIds.length === 0) return null;
|
||
return {
|
||
version: 1,
|
||
viewOperationId: `${params.operationId}:view`,
|
||
operation: nodeIds.length > 1 ? "focus_nodes" : "focus_selection",
|
||
projectId: params.projectId,
|
||
nodeIds,
|
||
anchorNodeId: nodeIds[0],
|
||
padding: 120,
|
||
persist: false,
|
||
reason: params.reason || "model_planned_focus",
|
||
};
|
||
}
|
||
|
||
function buildContextSummary(context: CanvasAgentContext) {
|
||
return {
|
||
projectId: context.projectId,
|
||
canvasRevision: context.canvasRevision,
|
||
selectedNodeIds: context.selectedNodeIds,
|
||
viewport: context.viewport,
|
||
selectedNodes: context.selectedNodes.slice(0, 12).map((node) => ({
|
||
nodeId: node.nodeId,
|
||
type: node.type,
|
||
title: node.title,
|
||
x: node.x,
|
||
y: node.y,
|
||
width: node.width,
|
||
height: node.height,
|
||
})),
|
||
candidateNodes: context.candidateNodes.slice(0, 12).map((node) => ({
|
||
nodeId: node.nodeId,
|
||
type: node.type,
|
||
title: node.title,
|
||
x: node.x,
|
||
y: node.y,
|
||
width: node.width,
|
||
height: node.height,
|
||
})),
|
||
slots: context.slots.slice(0, 12).map((slot) => ({
|
||
nodeId: slot.nodeId,
|
||
slotType: slot.slotType,
|
||
label: slot.label,
|
||
isSelected: slot.isSelected,
|
||
fillPolicy: slot.fillPolicy,
|
||
})),
|
||
annotations: context.annotations.slice(0, 12).map((annotation) => ({
|
||
id: annotation.id,
|
||
sourceNodeId: annotation.sourceNodeId,
|
||
type: annotation.type,
|
||
text: annotation.text,
|
||
})),
|
||
};
|
||
}
|
||
|
||
export function shouldConsiderCanvasAgentModelPlanner(params: {
|
||
userText: string;
|
||
context?: CanvasAgentContext | null;
|
||
}) {
|
||
if (!params.context?.projectId) return false;
|
||
const text = params.userText.trim().toLowerCase();
|
||
if (!text) return false;
|
||
|
||
const hasViewIntent =
|
||
/视角|视野|画布|聚焦|定位|居中|看全局|看整体|看全部|缩小|放大|拉近|拉远|zoom/.test(
|
||
text
|
||
);
|
||
const hasLinkIntent = /连起来|连线|连接|关联|参考关系|版本关系|关系/.test(text);
|
||
const hasTextIntent =
|
||
/文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test(
|
||
text
|
||
);
|
||
const hasAnnotationIntent = /批注|标注|备注|note|annotation/.test(text);
|
||
const hasSlotIntent = hasCanvasAgentSlotCreationIntent(text);
|
||
const hasHighRiskIntent = Boolean(inferHighRiskOperation(text));
|
||
const hasMediaGenerationIntent =
|
||
/生成|出图|做一版|再出|换背景|背景|抠图|去水印|高清|扩图|重绘|变体|图层|多角度|洗图/.test(
|
||
text
|
||
);
|
||
|
||
if (
|
||
hasMediaGenerationIntent &&
|
||
!hasViewIntent &&
|
||
!hasLinkIntent &&
|
||
!hasTextIntent &&
|
||
!hasAnnotationIntent &&
|
||
!hasSlotIntent &&
|
||
!hasHighRiskIntent
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
return (
|
||
hasViewIntent ||
|
||
hasLinkIntent ||
|
||
hasTextIntent ||
|
||
hasAnnotationIntent ||
|
||
hasSlotIntent ||
|
||
hasHighRiskIntent
|
||
);
|
||
}
|
||
|
||
export function normalizeCanvasAgentModelPlannerDecision(params: {
|
||
decision: unknown;
|
||
context: CanvasAgentContext;
|
||
projectId: string;
|
||
messageId: string;
|
||
chatId: string;
|
||
userText: string;
|
||
}): CanvasAgentModelPlannerOutcome {
|
||
const parsed = plannerDecisionSchema.safeParse(params.decision);
|
||
if (!parsed.success) {
|
||
return { kind: "none", reason: "invalid_model_planner_decision" };
|
||
}
|
||
|
||
const decision = parsed.data;
|
||
const reason = trimString(decision.reason, 280) || undefined;
|
||
const operationId = `${params.messageId}:canvas-model-${decision.action}`;
|
||
const explicitRisk = trimString(decision.riskOperation, 80);
|
||
const inferredRisk = inferHighRiskOperation(params.userText);
|
||
const riskOperation =
|
||
decision.action === "confirmation_required"
|
||
? explicitRisk || inferredRisk || "other"
|
||
: inferredRisk;
|
||
if (riskOperation) {
|
||
const targetNodeIds = getDecisionNodeIds({
|
||
decision,
|
||
context: params.context,
|
||
allowSelectedFallback: true,
|
||
maxItems: 20,
|
||
});
|
||
const canBuildMovePlan =
|
||
(riskOperation === "move_nodes" || riskOperation === "batch_move") &&
|
||
targetNodeIds.length > 0;
|
||
const canBuildDeletePlan = riskOperation === "delete_nodes" && targetNodeIds.length > 0;
|
||
const operationPlan: CanvasOperationPlan | undefined = canBuildMovePlan
|
||
? {
|
||
version: 1,
|
||
operationId: operationId.replace(`canvas-model-${decision.action}`, "canvas-move"),
|
||
operation: "move_nodes",
|
||
projectId: params.projectId,
|
||
baseCanvasRevision: params.context.canvasRevision,
|
||
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
|
||
targetNodeIds,
|
||
layoutStrategy: inferMoveLayoutStrategy(params.userText),
|
||
spacing: 40,
|
||
preserveOriginal: true,
|
||
metadata: {
|
||
messageId: params.messageId,
|
||
chatId: params.chatId,
|
||
planner: "model",
|
||
prompt: params.userText,
|
||
pendingConfirmation: true,
|
||
riskOperation,
|
||
},
|
||
}
|
||
: canBuildDeletePlan
|
||
? {
|
||
version: 1,
|
||
operationId: operationId.replace(
|
||
`canvas-model-${decision.action}`,
|
||
"canvas-delete"
|
||
),
|
||
operation: "delete_nodes",
|
||
projectId: params.projectId,
|
||
baseCanvasRevision: params.context.canvasRevision,
|
||
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
|
||
targetNodeIds,
|
||
preserveOriginal: false,
|
||
metadata: {
|
||
messageId: params.messageId,
|
||
chatId: params.chatId,
|
||
planner: "model",
|
||
prompt: params.userText,
|
||
pendingConfirmation: true,
|
||
riskOperation,
|
||
},
|
||
}
|
||
: undefined;
|
||
return {
|
||
kind: "confirmation_required",
|
||
riskOperation,
|
||
targetNodeIds,
|
||
operationPlan,
|
||
viewPlan: operationPlan && operationPlan.operation !== "delete_nodes"
|
||
? buildViewPlan({
|
||
action: "focus_nodes",
|
||
context: params.context,
|
||
projectId: params.projectId,
|
||
operationId: operationPlan.operationId,
|
||
nodeIds: targetNodeIds,
|
||
reason: "focus_pending_move_nodes",
|
||
}) || undefined
|
||
: undefined,
|
||
reply: trimString(decision.reply, 280) || defaultConfirmationReply(riskOperation),
|
||
reason,
|
||
};
|
||
}
|
||
|
||
if (decision.action === "none") {
|
||
return { kind: "none", reason };
|
||
}
|
||
|
||
if (decision.action === "create_agent_slot") {
|
||
if (hasCombinedMediaExecutionIntent(params.userText)) {
|
||
return { kind: "none", reason: "slot_creation_combined_with_media_execution" };
|
||
}
|
||
|
||
const operationPlan = buildCanvasAgentSlotCreationPlan({
|
||
context: params.context,
|
||
projectId: params.projectId,
|
||
messageId: params.messageId,
|
||
chatId: params.chatId,
|
||
userText: params.userText,
|
||
operationId,
|
||
slotType: decision.slotType,
|
||
slotLabel: decision.slotLabel,
|
||
aspectRatio: decision.aspectRatio,
|
||
});
|
||
if (!operationPlan) return { kind: "none", reason: "missing_slot_creation_intent" };
|
||
|
||
const slotLabel = operationPlan.slot?.label || "Agent 槽位";
|
||
return {
|
||
kind: "operation",
|
||
operationPlan,
|
||
reply:
|
||
trimString(decision.reply, 280) ||
|
||
`已在当前视口创建${slotLabel}。`,
|
||
reason,
|
||
};
|
||
}
|
||
|
||
if (
|
||
decision.action === "focus_nodes" ||
|
||
decision.action === "fit_all" ||
|
||
decision.action === "zoom_in" ||
|
||
decision.action === "zoom_out"
|
||
) {
|
||
const nodeIds = getDecisionNodeIds({
|
||
decision,
|
||
context: params.context,
|
||
allowSelectedFallback: decision.action === "focus_nodes",
|
||
maxItems: 10,
|
||
});
|
||
const viewPlan = buildViewPlan({
|
||
action: decision.action,
|
||
context: params.context,
|
||
projectId: params.projectId,
|
||
operationId,
|
||
nodeIds,
|
||
reason,
|
||
});
|
||
if (!viewPlan) return { kind: "none", reason: "missing_view_target" };
|
||
return {
|
||
kind: "view",
|
||
viewPlan,
|
||
reply:
|
||
trimString(decision.reply, 280) ||
|
||
(decision.action === "fit_all"
|
||
? "已切到画布全局视图。"
|
||
: decision.action === "zoom_in"
|
||
? "已放大画布视角。"
|
||
: decision.action === "zoom_out"
|
||
? "已缩小画布视角。"
|
||
: "已聚焦到当前画布对象。"),
|
||
reason,
|
||
};
|
||
}
|
||
|
||
if (decision.action === "link_nodes") {
|
||
const nodeIds = getDecisionNodeIds({
|
||
decision,
|
||
context: params.context,
|
||
allowSelectedFallback: true,
|
||
maxItems: 20,
|
||
});
|
||
if (nodeIds.length < 2) {
|
||
return { kind: "none", reason: "link_nodes_requires_two_targets" };
|
||
}
|
||
const operationPlan: CanvasOperationPlan = {
|
||
version: 1,
|
||
operationId,
|
||
operation: "link_nodes",
|
||
projectId: params.projectId,
|
||
baseCanvasRevision: params.context.canvasRevision,
|
||
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
|
||
sourceNodeIds: nodeIds,
|
||
edgeType: "reference",
|
||
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,
|
||
reason: "focus_model_linked_nodes",
|
||
}) || undefined,
|
||
reply: trimString(decision.reply, 280) || "已为选中的画布节点建立参考连线。",
|
||
reason,
|
||
};
|
||
}
|
||
|
||
if (decision.action === "add_annotation") {
|
||
const knownNodes = buildKnownNodeMap(params.context);
|
||
const targetNodeIds = getDecisionNodeIds({
|
||
decision,
|
||
context: params.context,
|
||
allowSelectedFallback: true,
|
||
maxItems: 10,
|
||
}).filter((nodeId) => {
|
||
const type = knownNodes.get(nodeId)?.type;
|
||
return type === "image" || type === "video";
|
||
});
|
||
const textContent = trimString(decision.textContent, 1000);
|
||
if (targetNodeIds.length === 0 || !textContent) {
|
||
return { kind: "none", reason: "add_annotation_missing_target_or_content" };
|
||
}
|
||
|
||
const operationPlan: CanvasOperationPlan = {
|
||
version: 1,
|
||
operationId,
|
||
operation: "add_annotation",
|
||
projectId: params.projectId,
|
||
baseCanvasRevision: params.context.canvasRevision,
|
||
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
|
||
targetNodeIds,
|
||
textContent,
|
||
annotations: [
|
||
{
|
||
type: "text-note",
|
||
text: textContent,
|
||
color: "#f59e0b",
|
||
},
|
||
],
|
||
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_annotated_nodes",
|
||
}) || undefined,
|
||
reply: trimString(decision.reply, 280) || "已给选中的画布节点添加批注。",
|
||
reason,
|
||
};
|
||
}
|
||
|
||
if (decision.action === "update_text") {
|
||
const knownNodes = buildKnownNodeMap(params.context);
|
||
const requestedIds = getDecisionNodeIds({
|
||
decision,
|
||
context: params.context,
|
||
allowSelectedFallback: true,
|
||
maxItems: 10,
|
||
});
|
||
const targetNodeIds = requestedIds.filter(
|
||
(nodeId) => knownNodes.get(nodeId)?.type === "text"
|
||
);
|
||
const textContent = trimString(decision.textContent, 4000);
|
||
if (targetNodeIds.length === 0 || !textContent) {
|
||
return { kind: "none", reason: "update_text_missing_target_or_content" };
|
||
}
|
||
const operationPlan: CanvasOperationPlan = {
|
||
version: 1,
|
||
operationId,
|
||
operation: "update_text",
|
||
projectId: params.projectId,
|
||
baseCanvasRevision: params.context.canvasRevision,
|
||
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
|
||
targetNodeIds,
|
||
textContent,
|
||
preserveOriginal: false,
|
||
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_updated_text_nodes",
|
||
}) || undefined,
|
||
reply: trimString(decision.reply, 280) || "已更新选中的文本节点。",
|
||
reason,
|
||
};
|
||
}
|
||
|
||
return { kind: "none", reason: "unsupported_model_planner_action" };
|
||
}
|
||
|
||
export async function planCanvasAgentOperationWithModel(params: {
|
||
model: LanguageModel;
|
||
userText: string;
|
||
context: CanvasAgentContext;
|
||
projectId: string;
|
||
messageId: string;
|
||
chatId: string;
|
||
abortSignal?: AbortSignal;
|
||
}): Promise<CanvasAgentModelPlannerOutcome> {
|
||
if (
|
||
!shouldConsiderCanvasAgentModelPlanner({
|
||
userText: params.userText,
|
||
context: params.context,
|
||
})
|
||
) {
|
||
return { kind: "none", reason: "planner_not_relevant" };
|
||
}
|
||
|
||
const { object } = await generateObject({
|
||
model: params.model,
|
||
schema: plannerDecisionSchema,
|
||
temperature: 0,
|
||
abortSignal: params.abortSignal || AbortSignal.timeout(18_000),
|
||
system: [
|
||
"你是无限画布 Agent 的受控操作规划器。",
|
||
"你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点、给图片或视频节点添加批注、创建空 Agent 槽位。",
|
||
"图片/视频生成、换背景、抠图、高清、扩图、重绘等媒体任务必须返回 action=none,交给其他执行器。",
|
||
"当用户明确要求新建/添加空图片槽位、视频槽位、UI槽位、空框或占位时,可以返回 action=create_agent_slot;如果同时要求生成或填充媒体,返回 action=none。",
|
||
"删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。",
|
||
"只能使用上下文中出现的 nodeId。不要编造节点 ID。",
|
||
"如果用户只是聊天或目标不明确,返回 action=none。",
|
||
].join("\n"),
|
||
prompt: JSON.stringify(
|
||
{
|
||
userText: params.userText,
|
||
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",
|
||
nodeIds: "只填上下文存在的 nodeId",
|
||
textContent: "update_text 时填写最终文本;add_annotation 时填写批注内容",
|
||
slotType: "create_agent_slot 时填写 image/video/ui-screen",
|
||
slotLabel: "create_agent_slot 时可填写槽位名称",
|
||
aspectRatio: "create_agent_slot 时可填写 1:1/16:9 等比例",
|
||
riskOperation:
|
||
"confirmation_required 时填写 replace_node_media/delete_nodes/move_nodes/overwrite_slot/batch_move/other",
|
||
reply: "一句给用户看的中文短回复",
|
||
},
|
||
},
|
||
null,
|
||
2
|
||
),
|
||
});
|
||
|
||
return normalizeCanvasAgentModelPlannerDecision({
|
||
decision: object,
|
||
context: params.context,
|
||
projectId: params.projectId,
|
||
messageId: params.messageId,
|
||
chatId: params.chatId,
|
||
userText: params.userText,
|
||
});
|
||
}
|