feat(canvas): add capability runtime and MCP tools

This commit is contained in:
134 2026-07-14 12:49:48 +08:00
parent 5a7121dceb
commit 1b70c1d678
30 changed files with 3867 additions and 327 deletions

View File

@ -7,6 +7,7 @@ import type {
ResolvedTargetAssets, ResolvedTargetAssets,
} from "@/lib/ai/context-memory/types"; } from "@/lib/ai/context-memory/types";
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol"; import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
const chatApiLogger = createLogger("chat.stream"); const chatApiLogger = createLogger("chat.stream");
@ -422,6 +423,19 @@ export function writeCanvasViewOperationChunk(
); );
} }
export function writeCanvasCapabilityCommandChunk(
writer: StreamWriter,
command: CanvasCapabilityClientCommand,
label: string
) {
writeStructuredDataChunk(
writer,
"data-canvasCapabilityCommand",
command,
label
);
}
type AgentTargetLockRefPreview = { type AgentTargetLockRefPreview = {
url: string; url: string;
title?: string; title?: string;

View File

@ -90,6 +90,10 @@ import {
hasCanvasAgentPanViewIntent, hasCanvasAgentPanViewIntent,
} from "@/lib/canvas/agent-view-pan"; } from "@/lib/canvas/agent-view-pan";
import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback"; import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback";
import {
createCanvasCapabilityTools,
getCanvasCapabilityToolNames,
} from "@/lib/canvas/capabilities/ai-sdk-tools";
import { import {
getCanvasAgentOperationErrorMessage, getCanvasAgentOperationErrorMessage,
normalizeCanvasAgentOperationErrorCode, normalizeCanvasAgentOperationErrorCode,
@ -392,6 +396,7 @@ import {
writeAgentTargetLockChunk, writeAgentTargetLockChunk,
writeAssistantTextChunk, writeAssistantTextChunk,
writeCanvasMediaChunk, writeCanvasMediaChunk,
writeCanvasCapabilityCommandChunk,
writeCanvasOperationChunk, writeCanvasOperationChunk,
writeCanvasViewOperationChunk, writeCanvasViewOperationChunk,
writeImageGenerationStatusChunk, writeImageGenerationStatusChunk,
@ -7716,6 +7721,17 @@ export async function POST(request: Request) {
const directAgentExecution = directAgentToolContext?.execution || null; const directAgentExecution = directAgentToolContext?.execution || null;
const directAgentToolTargetImageUrls = directAgentToolContext?.targetImageUrls || []; const directAgentToolTargetImageUrls = directAgentToolContext?.targetImageUrls || [];
const directAgentToolTargetImageUrl = directAgentToolContext?.targetImageUrl || null; const directAgentToolTargetImageUrl = directAgentToolContext?.targetImageUrl || null;
const selectedCanvasImageNodeIds = (
requestCanvasAgentContext?.selectedNodes || []
)
.filter((node) => node.type === "image")
.map((node) => node.nodeId)
.filter(Boolean);
const shouldUseCanvasCapabilityToolLoop = Boolean(
contextPayload?.source === "canvas" &&
contextPayload.projectId &&
selectedCanvasImageNodeIds.length > 0
);
if ( if (
shouldRunAgentConversation && shouldRunAgentConversation &&
@ -8265,6 +8281,7 @@ export async function POST(request: Request) {
shouldRunAgentConversation && shouldRunAgentConversation &&
!smartAutoNeedsClarification && !smartAutoNeedsClarification &&
directAgentExecution && directAgentExecution &&
!shouldUseCanvasCapabilityToolLoop &&
isDirectAgentImageEditToolName(directAgentExecution.toolName) isDirectAgentImageEditToolName(directAgentExecution.toolName)
) { ) {
const directAgentToolCapability = directAgentExecution.capability; const directAgentToolCapability = directAgentExecution.capability;
@ -8711,6 +8728,14 @@ export async function POST(request: Request) {
agentIdentityInstruction, agentIdentityInstruction,
recentDialogueSystemContext, recentDialogueSystemContext,
baseConversationSystemPrompt, baseConversationSystemPrompt,
shouldUseCanvasCapabilityToolLoop
? [
"当前请求来自无限画布,且用户已明确选中图片节点。",
"涉及选中图片的识别、编辑、生成、3D、改名、批注或删除时,必须调用已提供的画布能力工具,不得只用文字声称完成。",
"可以连续调用多个工具;下一步要处理上一步结果时,把上一步返回的 createdNodeIds 作为 nodeIds。",
"只有工具返回成功后才能告诉用户已完成;若工具返回 interactive,说明已在画布打开对应交互界面并等待用户操作。",
].join("\n")
: "",
] ]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
@ -10948,6 +10973,52 @@ export async function POST(request: Request) {
chatApiLogger.info("[Smart Auto Mode] 🚫 generateVideo tool disabled (manual video mode not selected)"); chatApiLogger.info("[Smart Auto Mode] 🚫 generateVideo tool disabled (manual video mode not selected)");
} }
if (
contextPayload?.source === "canvas" &&
contextPayload.projectId &&
selectedCanvasImageNodeIds.length > 0
) {
const canvasCapabilityTools = await createCanvasCapabilityTools({
projectId: contextPayload.projectId,
selectedNodeIds: selectedCanvasImageNodeIds,
userId: session.user.id,
requestHeaders: request.headers,
requestUrl: request.url,
defaultModelId: finalModelId,
destructiveConfirmed: isCanvasOperationConfirmText(requestTextContent),
callbacks: {
onClientCommand: (command, capability) => {
writeCanvasCapabilityCommandChunk(
dataStream as unknown as StreamWriter,
command,
`canvas-capability:${capability.id}:client-command`
);
},
onResult: (runtimeResult, capability) => {
if (!runtimeResult.operation) return;
writeCanvasOperationChunk(
dataStream as unknown as StreamWriter,
{
projectId: contextPayload.projectId!,
operationId: runtimeResult.task.requestId,
operation: capability.id,
status: "applied",
createdNodeIds: runtimeResult.operation.createdNodeIds,
createdNodeIdsByMediaItemId:
runtimeResult.operation.createdNodeIdsByMediaItemId,
updatedNodeIds: runtimeResult.operation.updatedNodeIds,
canvasRevision: runtimeResult.operation.canvasRevision,
message: `${capability.title}已完成`,
},
`canvas-capability:${capability.id}:operation`
);
},
},
});
Object.assign(tools, canvasCapabilityTools);
activeTools.push(...getCanvasCapabilityToolNames(canvasCapabilityTools));
}
chatApiLogger.info("[Chat API] Calling streamText with model:", (languageModel as any).modelId, (languageModel as any).provider); // 调试日志 chatApiLogger.info("[Chat API] Calling streamText with model:", (languageModel as any).modelId, (languageModel as any).provider); // 调试日志
chatApiLogger.info("[Chat API] Provider type:", (languageModel as any).provider); // 调试日志 chatApiLogger.info("[Chat API] Provider type:", (languageModel as any).provider); // 调试日志
@ -11938,9 +12009,13 @@ export async function POST(request: Request) {
model: languageModel, model: languageModel,
system: effectiveConversationSystemPrompt, system: effectiveConversationSystemPrompt,
messages: convertedMessages, messages: convertedMessages,
stopWhen: stepCountIs(5), stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5),
experimental_activeTools: experimental_activeTools:
finalModelId === "chat-model-reasoning" || isVolcanoVideoModel ? [] : activeTools, (finalModelId === "chat-model-reasoning" &&
!shouldUseCanvasCapabilityToolLoop) ||
isVolcanoVideoModel
? []
: activeTools,
experimental_transform: shouldDisableSmoothStream experimental_transform: shouldDisableSmoothStream
? undefined ? undefined
: smoothStream({ chunking: "word" }), : smoothStream({ chunking: "word" }),

View File

@ -0,0 +1,91 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { auth } from "@/app/(auth)/auth";
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const requestId = new URL(request.url).searchParams.get("requestId");
if (!requestId) {
return NextResponse.json({
success: true,
capabilities: getCanvasCapabilityManifest(),
});
}
const task = await getCanvasCapabilityTask({
requestId,
userId: session.user.id,
projectId: id,
});
if (!task) {
return NextResponse.json(
{ success: false, error: "能力任务不存在" },
{ status: 404 }
);
}
return NextResponse.json({ success: true, task });
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
try {
const { id } = await params;
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
const result = await executeCanvasCapabilityRuntime({
command,
projectId: id,
requestHeaders: request.headers,
requestUrl: request.url,
userId: session.user.id,
});
return NextResponse.json({ success: true, ...result });
} catch (error) {
if (
error instanceof CanvasCapabilityRuntimeError ||
error instanceof CanvasAgentMutationError
) {
return NextResponse.json(
{ success: false, error: error.message, code: error.code },
{ status: error.status }
);
}
if (error instanceof ZodError) {
return NextResponse.json(
{
success: false,
error: "画布能力参数无效",
issues: error.issues,
},
{ status: 400 }
);
}
const message = error instanceof Error ? error.message : "执行画布能力失败";
return NextResponse.json(
{ success: false, error: message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,38 @@
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { createCanvasCapabilityMcpServer } from "@/lib/canvas/capabilities/mcp-server";
export const dynamic = "force-dynamic";
export const maxDuration = 600;
async function handleMcpRequest(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: "未登录,无法访问画布 MCP 服务" },
{ status: 401 }
);
}
const { id: projectId } = await params;
const transport = new WebStandardStreamableHTTPServerTransport({
enableJsonResponse: true,
});
const server = await createCanvasCapabilityMcpServer({
projectId,
requestHeaders: request.headers,
requestUrl: request.url,
userId: session.user.id,
});
await server.connect(transport);
return transport.handleRequest(request);
}
export const POST = handleMcpRequest;
export const GET = handleMcpRequest;
export const DELETE = handleMcpRequest;

View File

@ -151,6 +151,7 @@ import {
type AgentTargetLockStreamPayload, type AgentTargetLockStreamPayload,
buildAgentProgressNodeSteps, buildAgentProgressNodeSteps,
type CanvasOperationStreamPayload, type CanvasOperationStreamPayload,
type CanvasCapabilityCommandStreamPayload,
type CanvasViewOperationStreamPayload, type CanvasViewOperationStreamPayload,
type CanvasMediaStreamPayload, type CanvasMediaStreamPayload,
filterCanvasMediaPayloadForUnhandledItems, filterCanvasMediaPayloadForUnhandledItems,
@ -165,6 +166,7 @@ import {
shouldRetryCanvasViewOperationAfterRefresh, shouldRetryCanvasViewOperationAfterRefresh,
shouldSkipCanvasMediaPayloadForHandledItems, shouldSkipCanvasMediaPayloadForHandledItems,
} from "@/components/canvas/chat-panel/canvas-agent-stream"; } from "@/components/canvas/chat-panel/canvas-agent-stream";
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
import { import {
mergePendingCanvasAgentTargetNodeIds, mergePendingCanvasAgentTargetNodeIds,
resolvePendingCanvasAgentTargetNode, resolvePendingCanvasAgentTargetNode,
@ -3232,6 +3234,9 @@ export function CanvasChatPanel({
// 🆕 物品拾取 // 🆕 物品拾取
const pendingPickedObjects = useCanvasStore((state) => state.pendingPickedObjects); const pendingPickedObjects = useCanvasStore((state) => state.pendingPickedObjects);
const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects); const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects);
const dispatchCanvasCapabilityCommand = useCanvasStore(
(state) => state.dispatchCanvasCapabilityCommand
);
const clearLatestGeneratedImageHighlights = useCallback(() => { const clearLatestGeneratedImageHighlights = useCallback(() => {
const currentNodes = useCanvasStore.getState().nodes; const currentNodes = useCanvasStore.getState().nodes;
@ -6626,6 +6631,24 @@ export function CanvasChatPanel({
return; return;
} }
if (dataPart.type === "data-canvasCapabilityCommand") {
try {
const rawPayload: CanvasCapabilityCommandStreamPayload =
typeof dataPart.data === "string"
? JSON.parse(dataPart.data)
: dataPart.data;
const command = canvasCapabilityClientCommandSchema.parse(rawPayload);
dispatchCanvasCapabilityCommand(command);
} catch (error) {
console.error(
"[CanvasChat] Failed to parse data-canvasCapabilityCommand:",
error
);
toast.error("无法打开画布交互工具");
}
return;
}
if (dataPart.type === "data-canvasOperation") { if (dataPart.type === "data-canvasOperation") {
try { try {
const payload: CanvasOperationStreamPayload = const payload: CanvasOperationStreamPayload =

View File

@ -1,4 +1,7 @@
import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol"; import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
export type CanvasCapabilityCommandStreamPayload = CanvasCapabilityClientCommand;
export type CanvasMediaStreamPayload = { export type CanvasMediaStreamPayload = {
messageId?: string; messageId?: string;

View File

@ -220,6 +220,12 @@ import {
} from "@/lib/ai-wash/config"; } from "@/lib/ai-wash/config";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors"; import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state"; import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import type {
CanvasCapabilityClientAction,
SelectedImageToolbarActionKey,
} from "@/lib/canvas/capabilities/types";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import type { import type {
CanvasTextEditPayloadItem, CanvasTextEditPayloadItem,
@ -282,16 +288,6 @@ interface CanvasNodeAnnotationDisplayItem {
createdByAgent?: boolean; createdByAgent?: boolean;
} }
interface LayerSplitBounds {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
}
const AI_CLOUD_IMAGE_ACTIONS = new Set([ const AI_CLOUD_IMAGE_ACTIONS = new Set([
"ai-wash", "ai-wash",
"rmbg", "rmbg",
@ -1477,7 +1473,10 @@ export function ImageNode({
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar; isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
const selectedImageInlineActionCount = const selectedImageInlineActionCount =
getSelectedImageInlineActionCount(canvasToolbarConfig); getSelectedImageInlineActionCount(canvasToolbarConfig);
const selectedImageToolbarActionCandidates = [ const selectedImageToolbarActionCandidates: ReadonlyArray<{
key: SelectedImageToolbarActionKey;
enabled: boolean;
}> = [
{ key: "pickObject", enabled: imagePickObjectEnabled }, { key: "pickObject", enabled: imagePickObjectEnabled },
{ key: "enhance", enabled: imageEnhanceEnabled }, { key: "enhance", enabled: imageEnhanceEnabled },
{ key: "removeBackground", enabled: imageRemoveBackgroundEnabled }, { key: "removeBackground", enabled: imageRemoveBackgroundEnabled },
@ -1497,9 +1496,7 @@ export function ImageNode({
{ key: "flipRotate", enabled: imageFlipRotateEnabled }, { key: "flipRotate", enabled: imageFlipRotateEnabled },
{ key: "promptRegenerate", enabled: imagePromptRegenerateEnabled }, { key: "promptRegenerate", enabled: imagePromptRegenerateEnabled },
{ key: "note", enabled: imageNoteEnabled }, { key: "note", enabled: imageNoteEnabled },
] as const; ];
type SelectedImageToolbarActionKey =
(typeof selectedImageToolbarActionCandidates)[number]["key"];
const enabledSelectedImageToolbarActions = const enabledSelectedImageToolbarActions =
selectedImageToolbarActionCandidates.filter((action) => action.enabled); selectedImageToolbarActionCandidates.filter((action) => action.enabled);
const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>( const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>(
@ -2037,6 +2034,7 @@ export function ImageNode({
>({}); >({});
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions); const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
const hydratedImageTaskRecoveryRef = useRef<string | null>(null); const hydratedImageTaskRecoveryRef = useRef<string | null>(null);
const processedCapabilityCommandIdsRef = useRef(new Set<string>());
const updateNode = useCanvasStore((state) => state.updateNode); const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode); const deleteNode = useCanvasStore((state) => state.deleteNode);
@ -2044,6 +2042,7 @@ export function ImageNode({
const selectNode = useCanvasStore((state) => state.selectNode); const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId); const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState); const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const addPendingChatAttachment = useCanvasStore( const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment (state) => state.addPendingChatAttachment
); );
@ -6043,35 +6042,6 @@ export function ImageNode({
await detectTextItems(true); await detectTextItems(true);
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]); }, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
clearImageNodeActionRequest();
if (imageNodeActionRequest.action === "box-cutout") {
handleEnterBoxCutoutMode();
return;
}
if (imageNodeActionRequest.action === "text-edit") {
void handleOpenTextEdit();
return;
}
if (imageNodeActionRequest.action === "local-edit") {
handleEnterLocalEditMode();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleEnterLocalEditMode,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
]);
const handleTextItemChange = (id: string, value: string) => { const handleTextItemChange = (id: string, value: string) => {
setTextEditItems((prev) => setTextEditItems((prev) =>
prev.map((item) => prev.map((item) =>
@ -6356,30 +6326,6 @@ export function ImageNode({
e.target.value = ""; e.target.value = "";
}; };
// 获取操作的中文名称
const getActionName = (action: string): string => {
const names: Record<string, string> = {
rmbg: "抠图",
watermark: "去印",
enhance: "增强",
extend: "扩图",
crop: "裁剪",
adjust: "调整",
"flip-rotate": "翻转与旋转",
resize: "调尺",
angle: "视角",
"layer-split": "图层拆分",
"scene-fusion": "批量处理",
annotate: "标注",
"text-edit": "改字",
note: "添加批注",
"prompt-regenerate": "重绘",
"box-cutout": "框选抠图",
"local-edit": "局部编辑",
};
return names[action] || action;
};
const openThreedGenerationDialog = useCallback( const openThreedGenerationDialog = useCallback(
(mode: "model" | "world") => { (mode: "model" | "world") => {
if (!data.src) { if (!data.src) {
@ -6642,8 +6588,55 @@ export function ImageNode({
updateNode, updateNode,
]); ]);
// 处理图片编辑操作 - 集成我们现有的 API const executeSharedToolbarCapability = async (
const handleAction = async (action: string) => { action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
) => {
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const capability = getCanvasCapabilityByClientAction(action);
if (!capability) {
toast.error("画布能力未注册");
return;
}
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const command = createCanvasCapabilityClientCommand({
capabilityId: capability.id,
nodeIds: [node.id],
projectId,
source: "toolbar",
});
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || `${capability.title}失败`);
}
const projectResult = await canvasService.getProject(projectId);
if (!projectResult.success || !projectResult.data?.canvasData) {
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
}
loadCanvas(projectResult.data.canvasData);
toast.success(`${capability.title}完成`);
} catch (error) {
const message = error instanceof Error ? error.message : `${capability.title}失败`;
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
// 处理图片编辑操作
const handleAction = async (action: CanvasCapabilityClientAction | "scene-fusion" | "annotate") => {
if (!data.src) { if (!data.src) {
toast.error("请先上传图片"); toast.error("请先上传图片");
return; return;
@ -6746,34 +6739,7 @@ export function ImageNode({
toast.info("图层拆分功能未开启"); toast.info("图层拆分功能未开启");
return; return;
} }
setIsCreatingDetachedResultNode(true); await executeSharedToolbarCapability("layer-split");
setIsProcessing(true);
try {
const result = await imageGenerationService.splitImageLayers(data.src);
const layers = result.data?.layers || [];
if (!result.success || layers.length === 0) {
toast.error(result.error || "图层拆分失败");
return;
}
const addedLayerCount = handleLayerSplitComplete(
layers.map((layer) => ({
originalUrl: layer.originalUrl,
title: layer.title,
layerIndex: layer.layerIndex,
totalLayers: layer.totalLayers,
bounds: layer.bounds ?? null,
}))
);
toast.success(`图层拆分完成,已新增 ${addedLayerCount} 个图层结果`);
} catch (error) {
console.error("图层拆分失败:", error);
toast.error("图层拆分失败");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
return; return;
} }
if (action === "box-cutout") { if (action === "box-cutout") {
@ -6835,102 +6801,13 @@ export function ImageNode({
return; return;
} }
const actionName = getActionName(action); if (
action === "rmbg" ||
setImageAdjustPanelOpen(false); action === "watermark" ||
setCropPanelOpen(false); action === "enhance" ||
setCropInteraction(null); action === "extend"
setFlipRotatePanelOpen(false); ) {
setAnglePanelOpen(false); await executeSharedToolbarCapability(action);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
// 先创建一个"处理中"状态的节点,显示在原图右侧
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "", // 暂时没有图片
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
connections: [node.id],
});
let result;
try {
switch (action) {
case "rmbg":
result = await imageGenerationService.removeBackground(data.src);
break;
case "watermark":
result = await imageGenerationService.removeWatermark(data.src);
break;
case "enhance":
result = await imageGenerationService.enhanceImage(data.src);
break;
case "extend":
result = await imageGenerationService.extendImage(data.src);
break;
default:
// 删除创建的节点
if (newNodeId) deleteNode(newNodeId);
return;
}
if (result?.success && result.data?.url) {
// 更新节点为完成状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
});
}
toast.success(`${actionName}处理完成`);
} else {
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error(result?.error || "处理失败");
}
} catch (error) {
console.error(`${action} 处理失败:`, error);
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error("处理失败,请重试");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
} }
}; };
@ -6974,7 +6851,16 @@ export function ImageNode({
const action = customEvent.detail?.action; const action = customEvent.detail?.action;
if (!targetNodeId || !action || targetNodeId !== node.id) return; if (!targetNodeId || !action || targetNodeId !== node.id) return;
void handleAction(action); if (action === "scene-fusion" || action === "annotate") {
void handleAction(action);
return;
}
const capability = getCanvasCapabilityByClientAction(
action as CanvasCapabilityClientAction
);
if (capability) {
void handleAction(capability.clientAction);
}
}; };
window.addEventListener( window.addEventListener(
@ -7036,105 +6922,6 @@ export function ImageNode({
] ]
); );
const handleLayerSplitComplete = useCallback(
(
layers: Array<{
originalUrl: string;
title?: string;
layerIndex: number;
totalLayers: number;
bounds?: {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
} | null;
}>
) => {
const validLayers = layers.filter(
(item) =>
typeof item?.originalUrl === "string" &&
item.originalUrl.trim().length > 0
);
if (validLayers.length === 0) {
throw new Error("没有可添加到画布的图层结果");
}
const isFullFrameLayer = (bounds?: LayerSplitBounds | null) => {
if (!bounds) return false;
return (
bounds.coverage >= 0.92 &&
bounds.width >= bounds.originalWidth - 2 &&
bounds.height >= bounds.originalHeight - 2
);
};
const backgroundLayers = validLayers
.filter((layer) => isFullFrameLayer(layer.bounds))
.sort(
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
);
const subjectLayers = validLayers
.filter((layer) => !isFullFrameLayer(layer.bounds))
.sort(
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
);
const orderedLayers = [...backgroundLayers, ...subjectLayers];
const groupGap = 28;
const groupOffsetX = node.size.width + groupGap;
const groupOffsetY = 0;
orderedLayers.forEach((layer, index) => {
const layerTitle = `${displayTitle}-${layer.title || `图层${layer.layerIndex || index + 1}`}`;
const bounds = layer.bounds;
const fullFrameLayer = isFullFrameLayer(bounds);
const targetPosition = {
x: node.position.x + groupOffsetX,
y: node.position.y + groupOffsetY,
};
const targetSize = { width: node.size.width, height: node.size.height };
addNode({
type: "image",
position: targetPosition,
size: targetSize,
data: {
src: layer.originalUrl,
status: "completed",
prompt: `图层拆分结果 ${layer.layerIndex}/${layer.totalLayers}`,
title: layerTitle,
originalSrc: data.src,
metadata: {
layerIndex: layer.layerIndex,
totalLayers: layer.totalLayers,
bounds: layer.bounds,
allowOverlap: true,
placementMode: "overlay",
layerSplitRole: fullFrameLayer ? "background" : "subject",
},
},
connections: [node.id],
});
});
return orderedLayers.length;
},
[
addNode,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
]
);
// 图片标注完成回调 // 图片标注完成回调
const handleAnnotateComplete = async ( const handleAnnotateComplete = async (
annotatedImageUrl: string, annotatedImageUrl: string,
@ -8364,6 +8151,78 @@ export function ImageNode({
setDownloadProgress(null); setDownloadProgress(null);
}; };
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
if (
processedCapabilityCommandIdsRef.current.has(
imageNodeActionRequest.requestId
)
) {
return;
}
processedCapabilityCommandIdsRef.current.add(imageNodeActionRequest.requestId);
const executeCommand = async () => {
const { action, args } = imageNodeActionRequest;
if (action === "add-to-chat") {
await handleAddToChat();
return;
}
if (action === "pick-object") {
handleStartPicking();
return;
}
if (action === "rename") {
const requestedTitle = normalizeImageTitle(String(args.title || ""));
if (requestedTitle) {
updateNode(node.id, {
data: { ...data, title: requestedTitle },
});
setDraftTitle(requestedTitle);
toast.success("图片名称已更新");
} else {
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}
return;
}
if (action === "download") {
await handleDownload();
return;
}
if (action === "delete") {
if (imageNodeActionRequest.confirmed === true) {
deleteNode(node.id);
}
return;
}
await handleAction(action);
};
void executeCommand().finally(() => {
processedCapabilityCommandIdsRef.current.delete(
imageNodeActionRequest.requestId
);
clearImageNodeActionRequest();
});
}, [
clearImageNodeActionRequest,
data,
deleteNode,
displayTitle,
handleAction,
handleAddToChat,
handleDownload,
handleStartPicking,
imageNodeActionRequest,
node.id,
updateNode,
]);
const imageAdjustOverlayStyle = useMemo<React.CSSProperties>(() => { const imageAdjustOverlayStyle = useMemo<React.CSSProperties>(() => {
if (boxCutoutMetrics) { if (boxCutoutMetrics) {
return { return {

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
AlertCircle, AlertCircle,
Box, Box,
@ -43,6 +43,7 @@ import {
type CanvasToolbarConfig, type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config"; } from "@/lib/canvas-toolbar-config";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { canvasService, model3DGenerationService } from "@/services/canvas";
import type { CanvasNode, Model3DNodeData } from "@/types/canvas"; import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
@ -61,6 +62,15 @@ const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = {
wireframe: false, wireframe: false,
}; };
const MODEL_3D_RECOVERY_INTERVAL_MS = 5000;
const MODEL_3D_RECOVERY_MAX_ATTEMPTS = 360;
function isTerminalModelFailureStatus(status: string) {
return ["failed", "error", "banned", "expired", "cancelled", "canceled"].includes(
status.trim().toLowerCase()
);
}
function inferModelFileName(modelUrl: string | null, nodeId: string) { function inferModelFileName(modelUrl: string | null, nodeId: string) {
const raw = String(modelUrl || "").trim(); const raw = String(modelUrl || "").trim();
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`; if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
@ -93,7 +103,12 @@ export function Model3DNode({
}: Model3DNodeProps) { }: Model3DNodeProps) {
const data = node.data as Model3DNodeData; const data = node.data as Model3DNodeData;
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const recoveryAttemptRef = useRef(0);
const recoveryTaskRef = useRef<string | null>(null);
const deleteNode = useCanvasStore((state) => state.deleteNode); const deleteNode = useCanvasStore((state) => state.deleteNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const model3dOpen3dItem = getCanvasToolbarItem( const model3dOpen3dItem = getCanvasToolbarItem(
canvasToolbarConfig, canvasToolbarConfig,
"model3dOpen3d" "model3dOpen3d"
@ -127,6 +142,17 @@ export function Model3DNode({
"model3dDelete" "model3dDelete"
); );
const viewportZoom = useCanvasStore((state) => state.viewport.zoom); const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
const taskId = String(data.metadata?.taskId || "").trim();
const assetPayload = readWorldAssetPayload(data);
const providerId = String(
data.metadata?.providerId || assetPayload?.providerId || ""
).trim();
const providerName = String(
data.metadata?.providerName || assetPayload?.providerName || ""
).trim();
const sourceImageUrl = String(
data.metadata?.sourceImageUrl || data.previewImage || ""
).trim();
const { data: modelAliasConfigResponse } = useSWR<{ const { data: modelAliasConfigResponse } = useSWR<{
success?: boolean; success?: boolean;
data?: PublicThreeDModelAliasConfig; data?: PublicThreeDModelAliasConfig;
@ -199,6 +225,224 @@ export function Model3DNode({
[placeholderViewportScale] [placeholderViewportScale]
); );
const persistCanvasStateSnapshot = useCallback(() => {
if (!projectId || typeof window === "undefined") return;
window.setTimeout(() => {
void canvasService.saveCanvasState(projectId, getCanvasState());
}, 0);
}, [getCanvasState, projectId]);
const persistCanvas3DAsset = useCallback(
async (asset: Record<string, unknown>) => {
if (!projectId) return;
try {
await fetch(`/api/canvas/${projectId}/model3d-assets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ asset }),
});
} catch (error) {
console.error("[Model3DNode] 保存 3D 资产失败:", error);
}
},
[projectId]
);
useEffect(() => {
if (data.status !== "generating" || !taskId) return;
const provider = String(data.metadata?.provider || "tripo-3d").trim();
if (provider !== "tripo-3d") return;
if (recoveryTaskRef.current !== taskId) {
recoveryTaskRef.current = taskId;
recoveryAttemptRef.current = 0;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const prompt = String(data.prompt || "图生 3D 模型").trim();
const modelVersion = String(data.modelVersion || "tripo-standard").trim();
const previewImage = String(data.previewImage || sourceImageUrl).trim();
const currentData = () => {
const currentNode = getCanvasState().nodes.find((item) => item.id === node.id);
return (currentNode?.data || data) as Model3DNodeData;
};
const persistTerminalAsset = async (params: {
error: string | null;
modelUrl: string | null;
previewImageUrl: string;
progress: number | null;
status: "failed" | "success";
taskAssetPayload?: Record<string, unknown> | null;
}) => {
await persistCanvas3DAsset({
taskId,
provider: "tripo-3d",
status: params.status,
mode: "image",
prompt,
modelVersion,
progress: params.progress,
modelUrl: params.modelUrl,
previewImageUrl: params.previewImageUrl,
error: params.error,
assetPayload: {
...(params.taskAssetPayload || {}),
providerId: providerId || null,
providerName: providerName || null,
sourceImageUrl,
},
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
};
const markFailed = async (message: string, progress: number | null) => {
const latest = currentData();
updateNode(node.id, {
data: {
...latest,
src: "",
previewImage: previewImage || sourceImageUrl,
status: "failed",
metadata: {
...(latest.metadata || {}),
taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
progress,
error: message,
},
} as Model3DNodeData,
});
await persistTerminalAsset({
error: message,
modelUrl: null,
previewImageUrl: previewImage || sourceImageUrl,
progress,
status: "failed",
});
toast.error(message);
};
const scheduleNextPoll = () => {
if (cancelled) return;
if (recoveryAttemptRef.current >= MODEL_3D_RECOVERY_MAX_ATTEMPTS) {
void markFailed("3D 模型任务同步超时,请稍后重试", null);
return;
}
timer = setTimeout(() => {
void pollTask();
}, MODEL_3D_RECOVERY_INTERVAL_MS);
};
async function pollTask() {
if (cancelled) return;
recoveryAttemptRef.current += 1;
const result = await model3DGenerationService.getTask(taskId, {
providerId: providerId || null,
});
if (cancelled) return;
if (!result.success || !result.data) {
scheduleNextPoll();
return;
}
const status = String(result.data.status || "unknown").toLowerCase();
const progress = result.data.progress ?? null;
if (status === "success") {
const modelUrl = String(result.data.modelUrl || "").trim();
const nextPreviewImage = String(
result.data.previewImageUrl || previewImage || sourceImageUrl
).trim();
if (!modelUrl) {
scheduleNextPoll();
return;
}
const taskAssetPayload =
result.data.assetPayload && typeof result.data.assetPayload === "object"
? (result.data.assetPayload as Record<string, unknown>)
: {};
const latest = currentData();
updateNode(node.id, {
data: {
...latest,
src: modelUrl,
previewImage: nextPreviewImage,
format: inferModelFormat(modelUrl),
status: "completed",
metadata: {
...(latest.metadata || {}),
taskId,
provider: "tripo-3d",
mode: "image",
sourceImageUrl,
progress: 100,
output: result.data.output || null,
assetPayload: {
...taskAssetPayload,
providerId: providerId || taskAssetPayload.providerId || null,
providerName: providerName || taskAssetPayload.providerName || null,
sourceImageUrl,
},
recoveredAt: new Date().toISOString(),
},
} as Model3DNodeData,
});
await persistTerminalAsset({
error: null,
modelUrl,
previewImageUrl: nextPreviewImage,
progress: 100,
status: "success",
taskAssetPayload,
});
toast.success("3D 模型已生成到画布");
return;
}
if (isTerminalModelFailureStatus(status)) {
await markFailed(result.data.errorMessage || "3D 模型生成失败", progress);
return;
}
const latest = currentData();
updateNode(node.id, {
data: {
...latest,
metadata: {
...(latest.metadata || {}),
progress,
},
} as Model3DNodeData,
});
scheduleNextPoll();
}
void pollTask();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [
data.modelVersion,
data.previewImage,
data.prompt,
data.status,
getCanvasState,
node.id,
persistCanvas3DAsset,
persistCanvasStateSnapshot,
providerId,
providerName,
sourceImageUrl,
taskId,
updateNode,
]);
const handleOpen = () => { const handleOpen = () => {
if (!primaryOpenUrl) { if (!primaryOpenUrl) {
toast.error("暂无可打开的地址"); toast.error("暂无可打开的地址");

View File

@ -0,0 +1,61 @@
# 无限画布 Capability Runtime 与 MCP 接入说明
## 目标
无限画布的图片工具栏、Agent 和 MCP 共用同一套能力定义与执行器。MCP 只负责协议适配,不复制抠图、增强、3D 等业务逻辑。
## 调用链
```text
图片工具栏 ─┐
AI SDK Tools ├─> Canvas Capability Registry ─> Runtime/Queue ─> 画布写回
MCP Tools ───┘ └──────────────> Client Command(交互面板)
```
- 能力注册:`lib/canvas/capabilities/registry.ts`
- 参数与命令协议:`lib/canvas/capabilities/client-command.ts`
- 统一运行时:`lib/canvas/capabilities/runtime.ts`
- 后台执行器:`lib/canvas/capabilities/headless-executor.ts`
- 持久化任务:`lib/canvas/capabilities/task-store.ts`
- 项目内顺序队列:`lib/canvas/capabilities/task-queue.ts`
- AI SDK 工具:`lib/canvas/capabilities/ai-sdk-tools.ts`
- MCP 适配器:`lib/canvas/capabilities/mcp-server.ts`
- 前端命令总线:`store/canvas/index.ts`
## HTTP 入口
- 应用能力接口:`GET/POST /api/canvas/:projectId/capabilities`
- MCP Streamable HTTP:`GET/POST/DELETE /api/mcp/canvas/:projectId`
两个入口都使用当前登录会话鉴权,运行时会再次校验画布归属和节点类型。MCP 调用必须传 `nodeIds`;连续调用时可将上一步返回的 `createdNodeIds` 传给下一步。
## 执行模式
- `headless`:服务端直接执行,完成后写入新节点。
- `interactive`:通过 SSE 下发客户端命令,打开裁剪、调色、局部编辑等现有面板。
- `hybrid`:参数完整时后台执行,否则打开交互面板。
- 删除等危险能力必须明确确认,并使用请求号保证幂等。
Tripo 3D 与 World Labs 3D 世界由 Agent/MCP 直接创建任务和结果节点。节点保存任务 ID,页面刷新后继续轮询并恢复最终结果。
## 部署
部署新版本前必须执行:
```bash
pnpm install --frozen-lockfile
pnpm db:migrate
pnpm build
```
迁移 `0090_add_canvas_capability_tasks.sql` 新增持久化任务表。未执行迁移时,能力任务无法启动。
## 增加服务商或能力
1. 在 Registry 新增或扩展能力 Schema、风险、模型策略和工具名称。
2. 在共享后台执行器中接入服务商路由;不要在 React 组件或 MCP 回调中直接实现服务商逻辑。
3. 结果统一返回媒体/3D 节点描述,由运行时写回画布。
4. 需要用户框选或配置时,复用现有 `clientAction` 和前端面板。
5. 添加能力契约、MCP 工具发现、写回和失败恢复测试。
服务商切换应由系统模型配置和现有服务层完成。Agent/MCP 只传模型别名或能力参数,不持有服务商密钥。

View File

@ -104,7 +104,10 @@ function findNode(nodes: any[], nodeId?: string) {
} }
function mediaNodeType(item: CanvasMediaItem) { function mediaNodeType(item: CanvasMediaItem) {
return item.type === "video" ? "video" : "image"; if (item.type === "video") return "video";
if (item.type === "model3d") return "model3d";
if (item.type === "world3d") return "world3d";
return "image";
} }
function buildMediaNode(params: { function buildMediaNode(params: {
@ -138,6 +141,13 @@ function buildMediaNode(params: {
const primarySourceNodeId = String( const primarySourceNodeId = String(
itemMetadata.sourceNodeId || planMetadata.sourceNodeId || params.sourceNodeIds[0] || "" itemMetadata.sourceNodeId || planMetadata.sourceNodeId || params.sourceNodeIds[0] || ""
).trim(); ).trim();
const modelVersion = String(
itemMetadata.modelVersion || planMetadata.modelVersion || ""
).trim();
const taskStatus =
itemMetadata.status === "completed" || itemMetadata.status === "failed"
? itemMetadata.status
: "generating";
const metadata = { const metadata = {
...planMetadata, ...planMetadata,
...itemMetadata, ...itemMetadata,
@ -160,29 +170,57 @@ function buildMediaNode(params: {
: undefined, : undefined,
}; };
let data: Record<string, unknown>;
if (type === "video") {
data = {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 视频结果",
metadata,
};
} else if (type === "model3d") {
data = {
src: String(itemMetadata.modelUrl || "").trim(),
previewImage: params.item.url,
prompt: params.item.prompt,
title: params.item.title || "Agent 3D 模型结果",
modelVersion: modelVersion || "tripo-standard",
format: String(itemMetadata.format || "glb"),
status: taskStatus,
metadata,
};
} else if (type === "world3d") {
data = {
imageUrl: params.item.url,
previewImage: params.item.url,
prompt: params.item.prompt,
title: params.item.title || "Agent 3D 世界结果",
modelVersion: modelVersion || "world-standard",
status: taskStatus,
activeView: "image",
firstPerson: false,
hotspots: [],
metadata,
};
} else {
data = {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 图片结果",
metadata,
};
}
return { return {
id: generateId(), id: generateId(),
type, type,
position: params.position, position: params.position,
size: params.size, size: params.size,
data: data,
type === "video"
? {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 视频结果",
metadata,
}
: {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 图片结果",
metadata,
},
connections: params.sourceNodeIds, connections: params.sourceNodeIds,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@ -0,0 +1,150 @@
import { tool, type ToolSet } from "ai";
import { z } from "zod";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { CANVAS_CAPABILITY_REGISTRY } from "@/lib/canvas/capabilities/registry";
import {
executeCanvasCapabilityRuntime,
type CanvasCapabilityRuntimeResult,
} from "@/lib/canvas/capabilities/runtime";
import type { CanvasCapabilityDefinition } from "@/lib/canvas/capabilities/types";
type CanvasCapabilityToolCallbacks = {
onClientCommand?: (
command: NonNullable<CanvasCapabilityRuntimeResult["clientCommand"]>,
capability: CanvasCapabilityDefinition
) => void;
onResult?: (
result: CanvasCapabilityRuntimeResult,
capability: CanvasCapabilityDefinition
) => void;
};
function isCapabilityAvailableForSelection(params: {
capability: CanvasCapabilityDefinition;
selectedNodeIds: string[];
}) {
const count = params.selectedNodeIds.length;
return (
params.capability.agent.exposed &&
count >= params.capability.selection.min &&
(!params.capability.selection.max || count <= params.capability.selection.max)
);
}
function buildToolDescription(capability: CanvasCapabilityDefinition) {
const modeDescription =
capability.executionMode === "interactive"
? "该工具会在用户画布中打开交互面板。"
: capability.executionMode === "headless"
? "该工具会在后台执行并把结果写回画布。"
: "参数完整时后台执行,否则在用户画布中打开交互面板。";
const aliases = capability.agent.aliases.length
? `常见表达:${capability.agent.aliases.join("、")}。`
: "";
return `${capability.description}${modeDescription}${aliases}默认作用于当前选中图片;连续操作时可把上一步返回的 createdNodeIds 传入 nodeIds。`;
}
export async function createCanvasCapabilityTools(params: {
callbacks?: CanvasCapabilityToolCallbacks;
defaultModelId?: string;
destructiveConfirmed?: boolean;
projectId: string;
requestHeaders: Headers;
requestUrl: string;
selectedNodeIds: string[];
userId: string;
}) {
const selectedNodeIds = Array.from(
new Set(
params.selectedNodeIds
.map((id) => String(id || "").trim())
.filter(Boolean)
)
);
const tools: ToolSet = {};
const { getEnabledCanvasCapabilityIds } = await import(
"@/lib/canvas/capabilities/availability"
);
const enabledCapabilityIds = await getEnabledCanvasCapabilityIds(
CANVAS_CAPABILITY_REGISTRY
);
for (const capability of CANVAS_CAPABILITY_REGISTRY) {
if (!enabledCapabilityIds.has(capability.id)) continue;
if (!isCapabilityAvailableForSelection({ capability, selectedNodeIds })) continue;
const toolName = capability.agent.toolName;
if (!toolName) continue;
tools[toolName] = tool({
description: buildToolDescription(capability),
inputSchema: z.object({
nodeIds: z
.array(z.string().trim().min(1).max(180))
.min(1)
.max(50)
.optional()
.describe("可选。传入上一个工具返回的 createdNodeIds 可链式处理新结果。"),
args: capability.inputSchema.optional().describe("能力参数。"),
}),
execute: async (input) => {
const rawArgs =
input.args && typeof input.args === "object" && !Array.isArray(input.args)
? input.args
: {};
const args = {
...rawArgs,
...(capability.modelPolicy === "agent-selected" &&
params.defaultModelId &&
!("modelId" in rawArgs)
? { modelId: params.defaultModelId }
: {}),
};
const command = createCanvasCapabilityClientCommand({
capabilityId: capability.id,
nodeIds: input.nodeIds?.length ? input.nodeIds : selectedNodeIds,
projectId: params.projectId,
args,
source: "agent",
confirmed:
capability.risk === "destructive"
? params.destructiveConfirmed === true
: undefined,
});
const result = await executeCanvasCapabilityRuntime({
command,
projectId: params.projectId,
requestHeaders: params.requestHeaders,
requestUrl: params.requestUrl,
userId: params.userId,
});
if (result.clientCommand) {
params.callbacks?.onClientCommand?.(result.clientCommand, capability);
}
params.callbacks?.onResult?.(result, capability);
return {
capabilityId: capability.id,
title: capability.title,
mode: result.mode,
status: result.task.status,
taskId: result.task.id,
requestId: result.task.requestId,
createdNodeIds: result.operation?.createdNodeIds || [],
updatedNodeIds: result.operation?.updatedNodeIds || [],
outputs: result.outputs || [],
clientAction: result.clientCommand
? capability.clientAction
: undefined,
};
},
});
}
return tools;
}
export function getCanvasCapabilityToolNames(tools: ToolSet) {
return Object.keys(tools);
}

View File

@ -0,0 +1,38 @@
import "server-only";
import {
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
isCanvasToolbarItemEnabled,
normalizeCanvasToolbarConfig,
type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config";
import type { CanvasCapabilityDefinition } from "@/lib/canvas/capabilities/types";
import { getSystemSetting } from "@/lib/db/queries";
export async function isCanvasCapabilityEnabled(
capability: CanvasCapabilityDefinition
) {
const enabledIds = await getEnabledCanvasCapabilityIds([capability]);
return enabledIds.has(capability.id);
}
export async function getEnabledCanvasCapabilityIds(
capabilities: readonly CanvasCapabilityDefinition[]
) {
const setting = await getSystemSetting({
key: CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
}).catch(() => null);
const config = normalizeCanvasToolbarConfig(setting?.value);
return new Set(
capabilities
.filter(
(capability) =>
!capability.toolbarSettingKey ||
isCanvasToolbarItemEnabled(
config,
capability.toolbarSettingKey as CanvasToolbarItemKey
)
)
.map((capability) => capability.id)
);
}

View File

@ -0,0 +1,59 @@
import { z } from "zod";
import { getCanvasCapability, parseCanvasCapabilityArgs } from "./registry";
import {
CANVAS_CAPABILITY_IDS,
type CanvasCapabilityCommandSource,
type CanvasCapabilityId,
} from "./types";
export const canvasCapabilityClientCommandSchema = z.object({
version: z.literal(1),
requestId: z.string().trim().min(1).max(180),
capabilityId: z.enum(CANVAS_CAPABILITY_IDS),
projectId: z.string().trim().min(1).max(180).optional(),
nodeIds: z.array(z.string().trim().min(1).max(180)).min(1).max(50),
args: z.record(z.unknown()).default({}),
source: z.enum(["toolbar", "agent", "mcp", "system"]),
confirmed: z.boolean().optional(),
createdAt: z.string().datetime(),
});
export type CanvasCapabilityClientCommand = z.infer<
typeof canvasCapabilityClientCommandSchema
>;
export function createCanvasCapabilityClientCommand(params: {
capabilityId: CanvasCapabilityId;
nodeIds: string[];
projectId?: string;
requestId?: string;
args?: unknown;
source: CanvasCapabilityCommandSource;
confirmed?: boolean;
createdAt?: string;
}): CanvasCapabilityClientCommand {
const capability = getCanvasCapability(params.capabilityId);
if (!capability) throw new Error(`Unknown canvas capability: ${params.capabilityId}`);
const nodeIds = Array.from(new Set(params.nodeIds.map((id) => id.trim()).filter(Boolean)));
if (nodeIds.length < capability.selection.min) {
throw new Error(`${capability.title} requires at least ${capability.selection.min} selected node(s)`);
}
if (capability.selection.max && nodeIds.length > capability.selection.max) {
throw new Error(`${capability.title} supports at most ${capability.selection.max} selected node(s)`);
}
if (capability.confirmation === "always" && params.confirmed !== true) {
throw new Error(`${capability.title} requires explicit confirmation`);
}
return canvasCapabilityClientCommandSchema.parse({
version: 1,
requestId: params.requestId || crypto.randomUUID(),
capabilityId: params.capabilityId,
projectId: params.projectId,
nodeIds,
args: parseCanvasCapabilityArgs(params.capabilityId, params.args),
source: params.source,
confirmed: params.confirmed,
createdAt: params.createdAt || new Date().toISOString(),
});
}

View File

@ -0,0 +1,100 @@
import { and, eq } from "drizzle-orm";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
export class CanvasCapabilityRuntimeError extends Error {
status: number;
code: string;
constructor(message: string, status = 400, code = "CANVAS_CAPABILITY_ERROR") {
super(message);
this.name = "CanvasCapabilityRuntimeError";
this.status = status;
this.code = code;
}
}
export type CanvasCapabilityTarget = {
node: Record<string, any>;
nodeId: string;
sourceImageUrl: string;
assetId?: string;
title: string;
};
export async function resolveCanvasCapabilityContext(params: {
command: CanvasCapabilityClientCommand;
projectId: string;
requiresRemoteImage?: boolean;
userId: string;
}) {
const [project] = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, params.projectId),
eq(canvasProject.userId, params.userId)
)
)
.limit(1);
if (!project) {
throw new CanvasCapabilityRuntimeError(
"画布不存在或无权访问",
404,
"CANVAS_CAPABILITY_PROJECT_NOT_FOUND"
);
}
const canvasData =
project.canvasData && typeof project.canvasData === "object"
? (project.canvasData as Record<string, any>)
: {};
const nodes = Array.isArray(canvasData.nodes) ? canvasData.nodes : [];
const nodeById = new Map<string, Record<string, any>>(
nodes.map((node: Record<string, any>) => [String(node?.id || ""), node])
);
const targets = params.command.nodeIds.map((nodeId) => {
const node = nodeById.get(nodeId);
if (!node) {
throw new CanvasCapabilityRuntimeError(
`选中节点不存在:${nodeId}`,
404,
"CANVAS_CAPABILITY_NODE_NOT_FOUND"
);
}
if (node.type !== "image") {
throw new CanvasCapabilityRuntimeError(
`能力仅支持图片节点:${nodeId}`,
400,
"CANVAS_CAPABILITY_INVALID_NODE_TYPE"
);
}
const data =
node.data && typeof node.data === "object" ? node.data : {};
const sourceImageUrl = String(data.src || data.url || "").trim();
if (params.requiresRemoteImage !== false && !/^https?:\/\//i.test(sourceImageUrl)) {
throw new CanvasCapabilityRuntimeError(
`图片尚未上传完成:${nodeId}`,
409,
"CANVAS_CAPABILITY_IMAGE_NOT_REMOTE"
);
}
return {
node,
nodeId,
sourceImageUrl,
assetId: String(data.assetId || "").trim() || undefined,
title: String(data.title || data.prompt || "图片").trim() || "图片",
} satisfies CanvasCapabilityTarget;
});
return {
project,
canvasData,
targets,
};
}

View File

@ -0,0 +1,44 @@
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import type { CanvasCapabilityDefinition } from "@/lib/canvas/capabilities/types";
function hasCompleteRect(args: Record<string, unknown>) {
return [args.x, args.y, args.width, args.height].every((value) =>
Number.isFinite(Number(value))
);
}
export function shouldExecuteCanvasCapabilityHeadlessly(params: {
capability: CanvasCapabilityDefinition;
command: CanvasCapabilityClientCommand;
}) {
const { capability, command } = params;
if (capability.executionMode === "headless") return true;
if (capability.executionMode === "interactive" || capability.executionMode === "local") {
return false;
}
switch (capability.id) {
case "image.ai_wash":
case "image.prompt_regenerate":
return Boolean(String(command.args.prompt || "").trim());
case "image.box_cutout":
return hasCompleteRect(command.args);
case "image.resize":
return (
Number.isFinite(Number(command.args.width)) &&
Number.isFinite(Number(command.args.height))
);
case "image.multi_angle":
case "image.generate_3d_model":
case "image.generate_3d_world":
return command.source === "agent" || command.source === "mcp";
case "image.rename":
return Boolean(String(command.args.title || "").trim());
case "image.note":
return Boolean(String(command.args.text || "").trim());
case "image.delete":
return command.confirmed === true;
default:
return false;
}
}

View File

@ -0,0 +1,471 @@
import {
executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service";
import { executeExtendJob } from "@/lib/ai/extend/service";
import { executeLayerSplitJob } from "@/lib/ai/layer-split/service";
import { executeResizeJob } from "@/lib/ai/resize/service";
import {
executeRmbgJob,
fetchImageBufferFromUrl,
} from "@/lib/ai/rmbg/service";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { requestLongTaskResult } from "@/lib/client/long-task";
import {
findStyleProImageModelByRef,
getStyleProImageModelEntries,
sortStyleProImageModelEntries,
} from "@/lib/style-pro/model-options";
export type CanvasCapabilityMediaResult = {
assetId?: string;
height?: number;
metadata?: Record<string, unknown>;
nodeType?: "image" | "model3d" | "world3d";
prompt?: string;
sourceNodeId: string;
title: string;
url: string;
width?: number;
};
type HeadlessExecutorParams = {
command: CanvasCapabilityClientCommand;
requestHeaders: Headers;
requestUrl: string;
targets: CanvasCapabilityTarget[];
userId: string;
};
function asFiniteNumber(value: unknown) {
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
function getCookieHeaders(headers: Headers) {
const cookie = headers.get("cookie") || "";
return cookie ? { cookie } : undefined;
}
async function postRuntimeEndpoint(params: {
body: Record<string, unknown>;
path: string;
requestHeaders: Headers;
requestUrl: string;
timeoutMs?: number;
}) {
const response = await fetch(new URL(params.path, params.requestUrl), {
method: "POST",
headers: {
"Content-Type": "application/json",
...getCookieHeaders(params.requestHeaders),
},
body: JSON.stringify(params.body),
signal: AbortSignal.timeout(params.timeoutMs || 20 * 60 * 1000),
});
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.success === false || payload?.error) {
throw new Error(
String(payload?.error || payload?.detail || `能力服务返回 ${response.status}`)
);
}
return payload as Record<string, any>;
}
async function postRuntimeFormData(params: {
formData: FormData;
path: string;
requestHeaders: Headers;
requestUrl: string;
timeoutMs?: number;
}) {
const response = await fetch(new URL(params.path, params.requestUrl), {
method: "POST",
headers: getCookieHeaders(params.requestHeaders),
body: params.formData,
signal: AbortSignal.timeout(params.timeoutMs || 20 * 60 * 1000),
});
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.success === false || payload?.error) {
throw new Error(
String(payload?.error || payload?.detail || `能力服务返回 ${response.status}`)
);
}
return payload as Record<string, any>;
}
function inferTripoImageType(imageUrl: string) {
try {
const extension = new URL(imageUrl).pathname.split(".").pop()?.toLowerCase();
if (extension === "png" || extension === "webp") return extension;
} catch {}
return "jpg";
}
async function resolveImageModel(modelRef?: unknown) {
const entries = sortStyleProImageModelEntries(
await getStyleProImageModelEntries({ includeHidden: false })
);
const requested = findStyleProImageModelByRef(entries, String(modelRef || ""));
const selected = requested || entries.find((entry) => entry.model.isDefault) || entries[0];
if (!selected) throw new Error("系统未配置可用的图片生成模型");
return selected;
}
async function executeForTarget(
params: HeadlessExecutorParams,
target: CanvasCapabilityTarget
): Promise<CanvasCapabilityMediaResult[]> {
const { command } = params;
const prompt = String(command.args.prompt || "").trim();
const common = {
sourceNodeId: target.nodeId,
prompt: prompt || undefined,
};
if (command.capabilityId === "image.remove_background") {
const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl);
const result = await executeRmbgJob({
userId: params.userId,
imageBuffer,
sourceImageUrl: target.sourceImageUrl,
prompt: prompt || "抠图处理",
skipGallerySave: false,
});
return [{
...common,
assetId: result.mediaAssetId || undefined,
title: `${target.title}-抠图`,
url: result.original_url,
}];
}
if (command.capabilityId === "image.extend") {
const result = await executeExtendJob({
userId: params.userId,
imageUrl: target.sourceImageUrl,
sourceImageUrl: target.sourceImageUrl,
prompt: prompt || "图像拓展处理",
xScale: asFiniteNumber(command.args.xScale),
yScale: asFiniteNumber(command.args.yScale),
requestId: command.requestId,
});
return [{
...common,
assetId: result.mediaAssetId || undefined,
title: `${target.title}-扩图`,
url: result.original_url,
}];
}
if (command.capabilityId === "image.resize") {
const width = asFiniteNumber(command.args.width);
const height = asFiniteNumber(command.args.height);
if (!width || !height) throw new Error("请提供完整的目标宽度和高度");
const result = await executeResizeJob({
userId: params.userId,
imageUrl: target.sourceImageUrl,
sourceImageUrl: target.sourceImageUrl,
canvasWidth: Math.round(width),
canvasHeight: Math.round(height),
backgroundColor: String(command.args.backgroundColor || "").trim() || undefined,
transparentBackground: command.args.transparentBackground === true,
mimeType: String(command.args.mimeType || "").trim() || undefined,
});
return [{
...common,
assetId: result.mediaAssetId || undefined,
height: result.canvas_height,
title: `${target.title}-${result.canvas_width}x${result.canvas_height}`,
url: result.original_url,
width: result.canvas_width,
}];
}
if (command.capabilityId === "image.box_cutout") {
const result = await executeBoxCutoutJob({
userId: params.userId,
imageUrl: target.sourceImageUrl,
sourceNodeId: target.nodeId,
cropRect: {
x: asFiniteNumber(command.args.x),
y: asFiniteNumber(command.args.y),
width: asFiniteNumber(command.args.width),
height: asFiniteNumber(command.args.height),
},
paddingRatio: asFiniteNumber(command.args.paddingRatio),
prompt: prompt || "框选抠图",
});
return [{
...common,
assetId: result.mediaAssetId || undefined,
metadata: {
cropRectApplied: result.cropRectApplied,
paddedCropRectApplied: result.paddedCropRectApplied,
},
title: `${target.title}-框选抠图`,
url: result.original_url,
}];
}
if (command.capabilityId === "image.layer_split") {
const result = await executeLayerSplitJob({
userId: params.userId,
imageUrl: target.sourceImageUrl,
sourceNodeId: target.nodeId,
prompt: prompt || undefined,
});
return result.layers.map((layer) => ({
...common,
assetId: layer.mediaAssetId || undefined,
metadata: {
bounds: layer.bounds || null,
layerIndex: layer.layerIndex,
model: result.model,
provider: result.provider,
totalLayers: layer.totalLayers,
},
title: layer.title || `${target.title}-图层${layer.layerIndex + 1}`,
url: layer.originalUrl,
}));
}
if (command.capabilityId === "image.enhance") {
const result = await requestLongTaskResult({
endpoint: new URL("/api/enhance", params.requestUrl).toString(),
payload: {
imageUrl: target.sourceImageUrl,
prompt: prompt || "图像增强处理",
scaleFactor: asFiniteNumber(command.args.scaleFactor),
},
pollingParams: {
prompt: prompt || "图像增强处理",
sourceImageUrl: target.sourceImageUrl,
},
timeoutMs: 10 * 60 * 1000,
headers: getCookieHeaders(params.requestHeaders),
});
if (!result?.success || !result.original_url) {
throw new Error(String(result?.error || result?.detail || "图像增强失败"));
}
return [{
...common,
title: `${target.title}-高清增强`,
url: String(result.original_url),
}];
}
if (command.capabilityId === "image.remove_watermark") {
const result = await requestLongTaskResult({
endpoint: new URL("/api/watermark-remove", params.requestUrl).toString(),
payload: { image_url: target.sourceImageUrl },
timeoutMs: 20 * 60 * 1000,
headers: getCookieHeaders(params.requestHeaders),
});
if (!result?.success || !result.original_url) {
throw new Error(String(result?.error || result?.detail || "去水印失败"));
}
return [{
...common,
title: `${target.title}-去水印`,
url: String(result.original_url),
}];
}
if (command.capabilityId === "image.multi_angle") {
const result = await postRuntimeEndpoint({
path: "/api/angle-edit",
requestUrl: params.requestUrl,
requestHeaders: params.requestHeaders,
body: {
image_url: target.sourceImageUrl,
horizontal_angle: asFiniteNumber(command.args.horizontalAngle) ?? 30,
vertical_angle: asFiniteNumber(command.args.verticalAngle) ?? 0,
zoom: asFiniteNumber(command.args.zoom) ?? 0,
},
});
const url = String(result.image_url || result.original_url || "").trim();
if (!url) throw new Error("多视角服务未返回图片");
return [{ ...common, title: `${target.title}-新视角`, url }];
}
if (command.capabilityId === "image.generate_3d_model") {
const modelVersion =
String(command.args.modelId || command.args.modelVersion || "").trim() ||
"tripo-standard";
const promptText = prompt || target.title || "图生 3D 模型";
const response = await postRuntimeEndpoint({
path: "/api/canvas/3d/tasks",
requestUrl: params.requestUrl,
requestHeaders: params.requestHeaders,
timeoutMs: 180_000,
body: {
taskType: "image_to_model",
imageUrl: target.sourceImageUrl,
imageType: inferTripoImageType(target.sourceImageUrl),
prompt: promptText,
modelVersion,
faceLimit: 120_000,
texture: true,
pbr: true,
autoSize: false,
quad: false,
generateParts: false,
smartLowPoly: false,
geometryQuality: "standard",
textureQuality: "standard",
textureAlignment: "original_image",
orientation: "default",
},
});
const task = response.data as Record<string, unknown> | undefined;
const taskId = String(task?.taskId || "").trim();
if (!taskId) throw new Error("3D 模型服务未返回任务 ID");
const providerId = String(task?.providerId || "").trim() || null;
const providerName = String(task?.providerName || "").trim() || null;
return [
{
...common,
nodeType: "model3d",
title: `${target.title}-3D模型`,
url: target.sourceImageUrl,
metadata: {
taskId,
provider: "tripo-3d",
mode: "image",
status: "generating",
sourceImageUrl: target.sourceImageUrl,
providerId,
providerName,
modelVersion,
generationStartedAt: new Date().toISOString(),
assetPayload: {
providerId,
providerName,
sourceImageUrl: target.sourceImageUrl,
},
},
},
];
}
if (command.capabilityId === "image.generate_3d_world") {
const modelVersion =
String(command.args.modelId || command.args.modelVersion || "").trim() ||
"world-standard";
const promptText = prompt || target.title || "图生 3D 世界";
const formData = new FormData();
formData.append("sourceUrl", target.sourceImageUrl);
formData.append("kind", "image");
const uploadResponse = await postRuntimeFormData({
formData,
path: "/api/canvas/world/upload",
requestUrl: params.requestUrl,
requestHeaders: params.requestHeaders,
timeoutMs: 180_000,
});
const upload = uploadResponse.data as Record<string, unknown> | undefined;
const mediaAssetId = String(upload?.mediaAssetId || "").trim();
if (!mediaAssetId) throw new Error("3D 世界服务未返回媒体资源 ID");
const providerId = String(upload?.providerId || "").trim() || null;
const providerName = String(upload?.providerName || "").trim() || null;
const taskResponse = await postRuntimeEndpoint({
path: "/api/canvas/world/tasks",
requestUrl: params.requestUrl,
requestHeaders: params.requestHeaders,
timeoutMs: 240_000,
body: {
mode: "image",
mediaAssetId,
providerId,
prompt: promptText,
displayName: target.title || promptText,
model: modelVersion,
disableRecaption: false,
makePublic: false,
},
});
const task = taskResponse.data as Record<string, unknown> | undefined;
const taskId = String(task?.taskId || "").trim();
if (!taskId) throw new Error("3D 世界服务未返回任务 ID");
const taskAssetPayload =
task?.assetPayload && typeof task.assetPayload === "object"
? (task.assetPayload as Record<string, unknown>)
: {};
return [
{
...common,
nodeType: "world3d",
title: `${target.title}-3D世界`,
url: target.sourceImageUrl,
metadata: {
taskId,
provider: "worldlabs-3d",
mode: "world-image",
status: "generating",
sourceImageUrl: target.sourceImageUrl,
providerId,
providerName,
modelVersion,
generationStartedAt: new Date().toISOString(),
assetPayload: {
...taskAssetPayload,
providerId,
providerName,
sourceImageUrl: target.sourceImageUrl,
},
},
},
];
}
if (
command.capabilityId === "image.prompt_regenerate" ||
command.capabilityId === "image.ai_wash"
) {
if (!prompt) throw new Error("请提供重绘提示词");
const model = await resolveImageModel(command.args.modelId);
const result = await generateCanvasImage({
prompt,
model: model.model.id,
ownerKey: params.userId,
imageCount: 1,
imageSize: String(command.args.imageSize || "2K"),
aspectRatio: String(command.args.aspectRatio || "auto"),
promptExtend: command.args.promptExtend === true,
referenceImage: target.sourceImageUrl,
debugLabel:
command.capabilityId === "image.ai_wash"
? "canvas-capability-ai-wash"
: "canvas-capability-regenerate",
debugMeta: {
capabilityId: command.capabilityId,
requestId: command.requestId,
sourceNodeId: target.nodeId,
},
});
const image = result.images[0];
if (!image?.originalUrl) throw new Error("图片模型未返回有效结果");
return [{
...common,
title:
command.capabilityId === "image.ai_wash"
? `${target.title}-AI洗图`
: `${target.title}-重绘`,
url: image.originalUrl,
}];
}
throw new Error(`尚未实现后台执行能力:${command.capabilityId}`);
}
export async function executeCanvasCapabilityHeadlessly(
params: HeadlessExecutorParams
) {
const results: CanvasCapabilityMediaResult[] = [];
for (const target of params.targets) {
results.push(...(await executeForTarget(params, target)));
}
return results;
}

View File

@ -0,0 +1,117 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { CANVAS_CAPABILITY_REGISTRY } from "@/lib/canvas/capabilities/registry";
const requestIdSchema = z.string().trim().min(1).max(180).optional();
const nodeIdsSchema = z
.array(z.string().trim().min(1).max(180))
.min(1)
.max(50)
.describe("要操作的画布图片节点 ID;多步调用可传入上一步返回的 createdNodeIds");
export async function createCanvasCapabilityMcpServer(params: {
filterDisabled?: boolean;
projectId: string;
requestHeaders: Headers;
requestUrl: string;
userId: string;
}) {
const server = new McpServer({
name: "buyi-canvas-capabilities",
version: "1.0.0",
});
const enabledCapabilityIds =
params.filterDisabled === false
? new Set(CANVAS_CAPABILITY_REGISTRY.map((capability) => capability.id))
: await import("@/lib/canvas/capabilities/availability").then(
({ getEnabledCanvasCapabilityIds }) =>
getEnabledCanvasCapabilityIds(CANVAS_CAPABILITY_REGISTRY)
);
for (const capability of CANVAS_CAPABILITY_REGISTRY) {
if (!enabledCapabilityIds.has(capability.id)) continue;
if (!(capability.mcp.exposed && capability.mcp.toolName)) continue;
const inputSchema = z.object({
nodeIds: nodeIdsSchema,
args: capability.inputSchema.optional().describe("能力参数"),
requestId: requestIdSchema.describe("幂等请求号;重试时必须复用同一个值"),
confirmed: z
.boolean()
.optional()
.describe("危险操作的明确确认;删除等能力必须为 true"),
});
server.registerTool(
capability.mcp.toolName,
{
title: capability.title,
description: capability.description,
inputSchema,
annotations: {
destructiveHint: capability.risk === "destructive",
idempotentHint: true,
openWorldHint: capability.risk === "external",
readOnlyHint: capability.risk === "read",
},
},
async (input) => {
try {
const command = createCanvasCapabilityClientCommand({
capabilityId: capability.id,
nodeIds: input.nodeIds,
projectId: params.projectId,
requestId: input.requestId,
args: input.args,
source: "mcp",
confirmed: input.confirmed,
});
const { executeCanvasCapabilityRuntime } = await import(
"@/lib/canvas/capabilities/runtime"
);
const result = await executeCanvasCapabilityRuntime({
command,
projectId: params.projectId,
requestHeaders: params.requestHeaders,
requestUrl: params.requestUrl,
userId: params.userId,
});
const createdNodeIds = result.operation?.createdNodeIds ?? [];
const summary =
result.mode === "interactive"
? `已创建客户端交互命令:${capability.title}`
: `已执行:${capability.title}${
createdNodeIds.length > 0
? `,创建 ${createdNodeIds.length} 个节点`
: ""
}`;
return {
content: [{ type: "text" as const, text: summary }],
structuredContent: {
capabilityId: capability.id,
mode: result.mode,
task: result.task,
...(result.operation ? { operation: result.operation } : {}),
...(result.outputs ? { outputs: result.outputs } : {}),
...(result.clientCommand
? { clientCommand: result.clientCommand }
: {}),
},
};
} catch (error) {
const message =
error instanceof Error ? error.message : `执行${capability.title}失败`;
return {
isError: true,
content: [{ type: "text" as const, text: message }],
};
}
}
);
}
return server;
}

View File

@ -0,0 +1,485 @@
import { z } from "zod";
import type {
CanvasCapabilityClientAction,
CanvasCapabilityDefinition,
CanvasCapabilityId,
CanvasCapabilityManifestEntry,
SelectedImageToolbarActionKey,
} from "./types";
const emptyArgsSchema = z.object({}).passthrough();
const promptArgsSchema = z
.object({
prompt: z.string().trim().min(1).max(8_000).optional(),
})
.passthrough();
const resizeArgsSchema = z
.object({
width: z.number().int().positive().max(16_384).optional(),
height: z.number().int().positive().max(16_384).optional(),
aspectRatio: z.string().trim().max(32).optional(),
})
.passthrough();
const extendArgsSchema = z
.object({
prompt: z.string().trim().min(1).max(8_000).optional(),
xScale: z.number().min(1.1).max(4).optional(),
yScale: z.number().min(1.1).max(4).optional(),
})
.passthrough();
const angleArgsSchema = z
.object({
horizontalAngle: z.number().min(0).max(360).optional(),
verticalAngle: z.number().min(-30).max(90).optional(),
zoom: z.number().min(0).max(10).optional(),
})
.passthrough();
const cropArgsSchema = z
.object({
x: z.number().min(0).optional(),
y: z.number().min(0).optional(),
width: z.number().positive().optional(),
height: z.number().positive().optional(),
})
.passthrough();
const transformArgsSchema = z
.object({
rotate: z.number().min(-360).max(360).optional(),
flipHorizontal: z.boolean().optional(),
flipVertical: z.boolean().optional(),
})
.passthrough();
type DefinitionInput = Omit<
CanvasCapabilityDefinition,
"selection" | "requiresRemoteImage" | "confirmation" | "agent" | "mcp"
> & {
selection?: Partial<CanvasCapabilityDefinition["selection"]>;
requiresRemoteImage?: boolean;
confirmation?: CanvasCapabilityDefinition["confirmation"];
agent?: Partial<CanvasCapabilityDefinition["agent"]>;
mcp?: Partial<CanvasCapabilityDefinition["mcp"]>;
};
function defineCapability(input: DefinitionInput): CanvasCapabilityDefinition {
const mcpToolName = input.id.replaceAll(".", "_");
return {
...input,
requiresRemoteImage: input.requiresRemoteImage ?? true,
selection: {
nodeTypes: ["image"],
min: input.selection?.min ?? 1,
...(input.selection?.max ? { max: input.selection.max } : {}),
},
confirmation: input.confirmation ?? "never",
agent: {
exposed: input.agent?.exposed ?? true,
aliases: input.agent?.aliases ?? [],
toolName: input.agent?.toolName ?? mcpToolName,
},
mcp: {
exposed: input.mcp?.exposed ?? true,
toolName: input.mcp?.toolName ?? mcpToolName,
},
};
}
export const CANVAS_CAPABILITY_REGISTRY = [
defineCapability({
id: "image.add_to_chat",
title: "添加到对话",
description: "将选中图片加入当前 Agent 对话上下文。",
clientAction: "add-to-chat",
toolbarSettingKey: "imageAddToChat",
executionMode: "local",
risk: "read",
modelPolicy: "none",
requiresRemoteImage: false,
inputSchema: emptyArgsSchema,
agent: { exposed: false },
mcp: { exposed: false },
}),
defineCapability({
id: "image.pick_object",
title: "识物",
description: "进入图片点选识物模式,并把识别结果加入对话上下文。",
clientAction: "pick-object",
toolbarActionKey: "pickObject",
toolbarSettingKey: "imagePickObject",
executionMode: "interactive",
risk: "read",
modelPolicy: "feature-setting",
confirmation: "interactive",
inputSchema: z
.object({ x: z.number().min(0).max(1).optional(), y: z.number().min(0).max(1).optional() })
.passthrough(),
agent: { aliases: ["识物", "识别物品", "选择物体"] },
}),
defineCapability({
id: "image.enhance",
title: "高清增强",
description: "使用系统配置的增强服务提升图片清晰度。",
clientAction: "enhance",
toolbarActionKey: "enhance",
toolbarSettingKey: "imageEnhance",
executionMode: "headless",
risk: "external",
modelPolicy: "feature-setting",
modelSettingKey: "canvas.imageEnhance",
inputSchema: z.object({ scaleFactor: z.number().min(1).max(8).optional() }).passthrough(),
agent: { toolName: "enhanceImage", aliases: ["高清", "增强", "变清晰"] },
}),
defineCapability({
id: "image.remove_background",
title: "抠图去背",
description: "调用系统抠图能力移除图片背景。",
clientAction: "rmbg",
toolbarActionKey: "removeBackground",
toolbarSettingKey: "imageRemoveBackground",
executionMode: "headless",
risk: "external",
modelPolicy: "feature-setting",
modelSettingKey: "canvas.removeBackground",
inputSchema: promptArgsSchema,
agent: { toolName: "removeBackground", aliases: ["抠图", "去背景", "透明背景"] },
}),
defineCapability({
id: "image.ai_wash",
title: "AI洗图",
description: "按管理员配置的预设、档位和模型执行 AI 洗图。",
clientAction: "ai-wash",
toolbarActionKey: "aiWashQuick",
executionMode: "hybrid",
risk: "external",
modelPolicy: "feature-setting",
modelSettingKey: "canvas.aiWash",
confirmation: "interactive",
inputSchema: z
.object({
prompt: z.string().trim().min(1).max(8_000).optional(),
presetId: z.string().trim().min(1).optional(),
modelId: z.string().trim().min(1).optional(),
levels: z.record(z.number().int().nonnegative()).optional(),
})
.passthrough(),
agent: { aliases: ["洗图", "AI洗图", "重新洗图"] },
}),
defineCapability({
id: "image.text_edit",
title: "图片改字",
description: "识别图片文字并打开可编辑文字面板。",
clientAction: "text-edit",
toolbarActionKey: "textEdit",
toolbarSettingKey: "imageTextEdit",
executionMode: "hybrid",
risk: "external",
modelPolicy: "feature-setting",
modelSettingKey: "canvas.textEdit",
confirmation: "interactive",
inputSchema: promptArgsSchema,
agent: { aliases: ["改字", "修改图片文字", "替换文案"] },
}),
defineCapability({
id: "image.box_cutout",
title: "框选抠图",
description: "框选图片区域后提取指定主体。",
clientAction: "box-cutout",
toolbarActionKey: "boxCutout",
toolbarSettingKey: "imageBoxCutout",
executionMode: "hybrid",
risk: "external",
modelPolicy: "feature-setting",
confirmation: "interactive",
inputSchema: cropArgsSchema,
agent: { toolName: "boxCutout", aliases: ["框选抠图", "抠出这部分"] },
}),
defineCapability({
id: "image.local_edit",
title: "局部编辑",
description: "按框选区域与区域提示词执行局部重绘。",
clientAction: "local-edit",
toolbarActionKey: "localEdit",
toolbarSettingKey: "imageLocalEdit",
executionMode: "hybrid",
risk: "external",
modelPolicy: "agent-selected",
confirmation: "interactive",
inputSchema: promptArgsSchema,
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
}),
defineCapability({
id: "image.scale_adjust",
title: "比例调整",
description: "按目标框等比缩放和移动选中主体。",
clientAction: "scale-adjust",
toolbarSettingKey: "imageLocalEdit",
executionMode: "hybrid",
risk: "external",
modelPolicy: "feature-setting",
confirmation: "interactive",
inputSchema: cropArgsSchema,
agent: { aliases: ["比例调整", "缩放主体", "调整主体位置"] },
}),
defineCapability({
id: "image.remove_watermark",
title: "去水印",
description: "使用系统配置的服务移除水印和画面瑕疵。",
clientAction: "watermark",
toolbarActionKey: "removeWatermark",
toolbarSettingKey: "imageRemoveWatermark",
executionMode: "headless",
risk: "external",
modelPolicy: "feature-setting",
modelSettingKey: "canvas.removeWatermark",
inputSchema: promptArgsSchema,
agent: { toolName: "removeWatermark", aliases: ["去水印", "去印", "清理水印"] },
}),
defineCapability({
id: "image.resize",
title: "尺寸调整",
description: "调整图片尺寸、比例和主体位置。",
clientAction: "resize",
toolbarActionKey: "resize",
toolbarSettingKey: "imageResize",
executionMode: "hybrid",
risk: "external",
modelPolicy: "feature-setting",
confirmation: "interactive",
inputSchema: resizeArgsSchema,
agent: { toolName: "resizeImage", aliases: ["调整尺寸", "改比例", "改分辨率"] },
}),
defineCapability({
id: "image.multi_angle",
title: "多视角",
description: "按水平角、俯仰角和景别生成主体新视角。",
clientAction: "angle",
toolbarActionKey: "multiAngle",
toolbarSettingKey: "imageMultiAngle",
executionMode: "hybrid",
risk: "external",
modelPolicy: "feature-setting",
confirmation: "interactive",
inputSchema: angleArgsSchema,
agent: { toolName: "angleEditImage", aliases: ["多视角", "换角度", "旋转视角"] },
}),
defineCapability({
id: "image.generate_3d_model",
title: "生成3D模型",
description: "使用系统配置的 Tripo 模型从图片生成 3D 模型。",
clientAction: "generate-3d-model",
toolbarActionKey: "generate3dModel",
toolbarSettingKey: "imageGenerate3dModel",
executionMode: "hybrid",
risk: "external",
modelPolicy: "feature-setting",
modelSettingKey: "canvas.tripo3d",
confirmation: "interactive",
inputSchema: z
.object({
modelId: z.string().trim().min(1).optional(),
prompt: z.string().trim().min(1).max(8_000).optional(),
})
.passthrough(),
agent: { aliases: ["生成3D模型", "转成3D模型", "Tripo"] },
}),
defineCapability({
id: "image.generate_3d_world",
title: "生成3D世界",
description: "使用系统配置的 World Labs 模型生成可漫游 3D 世界。",
clientAction: "generate-3d-world",
toolbarActionKey: "generate3dWorld",
toolbarSettingKey: "imageGenerate3dWorld",
executionMode: "hybrid",
risk: "external",
modelPolicy: "feature-setting",
modelSettingKey: "canvas.worldLabs3d",
confirmation: "interactive",
inputSchema: z
.object({
modelId: z.string().trim().min(1).optional(),
prompt: z.string().trim().min(1).max(8_000).optional(),
})
.passthrough(),
agent: { aliases: ["生成3D世界", "生成场景", "World Labs"] },
}),
defineCapability({
id: "image.layer_split",
title: "图层拆分",
description: "将图片拆分为可独立编辑的图层节点。",
clientAction: "layer-split",
toolbarActionKey: "layerSplit",
toolbarSettingKey: "imageLayerSplit",
executionMode: "headless",
risk: "external",
modelPolicy: "feature-setting",
modelSettingKey: "canvas.layerSplit",
inputSchema: emptyArgsSchema,
agent: { aliases: ["图层拆分", "拆图层", "分层"] },
}),
defineCapability({
id: "image.extend",
title: "扩图",
description: "向指定方向扩展画面并补全内容。",
clientAction: "extend",
toolbarActionKey: "extend",
toolbarSettingKey: "imageExtend",
executionMode: "headless",
risk: "external",
modelPolicy: "feature-setting",
modelSettingKey: "canvas.extend",
inputSchema: extendArgsSchema,
agent: { toolName: "extendImage", aliases: ["扩图", "补边", "扩展画面"] },
}),
defineCapability({
id: "image.crop",
title: "裁剪",
description: "打开裁剪面板,或按完整坐标直接裁剪。",
clientAction: "crop",
toolbarActionKey: "crop",
toolbarSettingKey: "imageCrop",
executionMode: "hybrid",
risk: "write",
modelPolicy: "none",
requiresRemoteImage: false,
confirmation: "interactive",
inputSchema: cropArgsSchema,
agent: { aliases: ["裁剪", "剪裁", "截取画面"] },
}),
defineCapability({
id: "image.adjust",
title: "画面调整",
description: "打开亮度、对比度、饱和度等图片调整面板。",
clientAction: "adjust",
toolbarActionKey: "adjust",
toolbarSettingKey: "imageAdjust",
executionMode: "interactive",
risk: "write",
modelPolicy: "none",
requiresRemoteImage: false,
confirmation: "interactive",
inputSchema: z.record(z.number()).optional().default({}),
agent: { aliases: ["调色", "调整亮度", "调整对比度"] },
}),
defineCapability({
id: "image.flip_rotate",
title: "翻转与旋转",
description: "翻转或旋转选中图片。",
clientAction: "flip-rotate",
toolbarActionKey: "flipRotate",
toolbarSettingKey: "imageFlipRotate",
executionMode: "hybrid",
risk: "write",
modelPolicy: "none",
requiresRemoteImage: false,
confirmation: "interactive",
inputSchema: transformArgsSchema,
agent: { aliases: ["翻转", "旋转图片", "镜像"] },
}),
defineCapability({
id: "image.prompt_regenerate",
title: "提示词重绘",
description: "按新的提示词和系统生图模型重绘选中图片。",
clientAction: "prompt-regenerate",
toolbarActionKey: "promptRegenerate",
toolbarSettingKey: "imagePromptRegenerate",
executionMode: "hybrid",
risk: "external",
modelPolicy: "agent-selected",
confirmation: "interactive",
inputSchema: promptArgsSchema,
agent: { toolName: "editTargetImage", aliases: ["重绘", "按提示词重做"] },
}),
defineCapability({
id: "image.note",
title: "添加批注",
description: "给图片添加可作为 Agent 上下文的批注。",
clientAction: "note",
toolbarActionKey: "note",
toolbarSettingKey: "imageNote",
executionMode: "hybrid",
risk: "write",
modelPolicy: "none",
requiresRemoteImage: false,
confirmation: "interactive",
inputSchema: z.object({ text: z.string().trim().max(1_000).optional() }).passthrough(),
agent: { aliases: ["添加批注", "备注", "标注"] },
}),
defineCapability({
id: "image.rename",
title: "重命名",
description: "修改选中图片节点标题。",
clientAction: "rename",
toolbarSettingKey: "imageRename",
executionMode: "hybrid",
risk: "write",
modelPolicy: "none",
requiresRemoteImage: false,
inputSchema: z.object({ title: z.string().trim().min(1).max(120).optional() }).passthrough(),
agent: { aliases: ["重命名", "改名", "修改标题"] },
}),
defineCapability({
id: "image.download",
title: "下载图片",
description: "下载选中图片资源。",
clientAction: "download",
toolbarSettingKey: "imageDownload",
executionMode: "local",
risk: "read",
modelPolicy: "none",
requiresRemoteImage: false,
inputSchema: emptyArgsSchema,
agent: { exposed: false },
mcp: { exposed: false },
}),
defineCapability({
id: "image.delete",
title: "删除图片",
description: "从当前画布删除选中图片节点。",
clientAction: "delete",
toolbarSettingKey: "imageDelete",
executionMode: "local",
risk: "destructive",
modelPolicy: "none",
requiresRemoteImage: false,
confirmation: "always",
inputSchema: z.object({ confirmed: z.literal(true).optional() }).passthrough(),
agent: { aliases: ["删除图片", "移除图片"] },
}),
] as const satisfies readonly CanvasCapabilityDefinition[];
const capabilityById = new Map<CanvasCapabilityId, CanvasCapabilityDefinition>(
CANVAS_CAPABILITY_REGISTRY.map((capability) => [capability.id, capability]),
);
const capabilityByClientAction = new Map<CanvasCapabilityClientAction, CanvasCapabilityDefinition>(
CANVAS_CAPABILITY_REGISTRY.map((capability) => [capability.clientAction, capability]),
);
const capabilityByToolbarAction = new Map<
SelectedImageToolbarActionKey,
CanvasCapabilityDefinition
>(
CANVAS_CAPABILITY_REGISTRY.flatMap((capability) =>
capability.toolbarActionKey ? [[capability.toolbarActionKey, capability]] : [],
),
);
export function getCanvasCapability(id: CanvasCapabilityId) {
return capabilityById.get(id) ?? null;
}
export function getCanvasCapabilityByClientAction(action: CanvasCapabilityClientAction) {
return capabilityByClientAction.get(action) ?? null;
}
export function getCanvasCapabilityByToolbarAction(action: SelectedImageToolbarActionKey) {
return capabilityByToolbarAction.get(action) ?? null;
}
export function parseCanvasCapabilityArgs(id: CanvasCapabilityId, value: unknown) {
const capability = getCanvasCapability(id);
if (!capability) throw new Error(`Unknown canvas capability: ${id}`);
return capability.inputSchema.parse(value ?? {});
}
export function getCanvasCapabilityManifest(): CanvasCapabilityManifestEntry[] {
return CANVAS_CAPABILITY_REGISTRY.map(({ inputSchema: _inputSchema, ...capability }) => capability);
}

View File

@ -0,0 +1,301 @@
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { isCanvasCapabilityEnabled } from "@/lib/canvas/capabilities/availability";
import {
CanvasCapabilityRuntimeError,
resolveCanvasCapabilityContext,
} from "@/lib/canvas/capabilities/context";
import { shouldExecuteCanvasCapabilityHeadlessly } from "@/lib/canvas/capabilities/execution-mode";
import {
executeCanvasCapabilityHeadlessly,
type CanvasCapabilityMediaResult,
} from "@/lib/canvas/capabilities/headless-executor";
import { getCanvasCapability } from "@/lib/canvas/capabilities/registry";
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
import {
createOrRecoverCanvasCapabilityTask,
MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS,
markCanvasCapabilityTaskFailed,
markCanvasCapabilityTaskRunning,
markCanvasCapabilityTaskSucceeded,
} from "@/lib/canvas/capabilities/task-store";
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
export type CanvasCapabilityRuntimeResult = {
clientCommand?: CanvasCapabilityClientCommand;
mode: "headless" | "interactive";
operation?: {
canvasRevision: string;
canvasUpdatedAt: string;
createdNodeIds: string[];
createdNodeIdsByMediaItemId: Record<string, string>;
updatedNodeIds: string[];
};
outputs?: Array<{
assetId?: string;
sourceNodeId: string;
title: string;
url: string;
}>;
task: {
attempts: number;
error: string | null;
id: string;
requestId: string;
result: unknown;
status: string;
version: number;
};
};
function serializeTask(task: {
attempts: number;
error: string | null;
id: string;
requestId: string;
result: unknown;
status: string;
version: number;
}) {
return {
attempts: task.attempts,
error: task.error,
id: task.id,
requestId: task.requestId,
result: task.result,
status: task.status,
version: task.version,
};
}
function buildMediaOperationPlan(params: {
command: CanvasCapabilityClientCommand;
mediaResults: CanvasCapabilityMediaResult[];
projectId: string;
projectRevision: string;
}) {
return {
version: 1,
operationId: params.command.requestId,
operation: "insert_beside",
projectId: params.projectId,
baseCanvasRevision: params.projectRevision,
sourceNodeIds: params.command.nodeIds,
targetNodeIds: params.command.nodeIds,
placement: "right",
placementStrategy: "beside_each_source",
preserveOriginal: true,
layoutStrategy: params.mediaResults.length > 4 ? "grid" : "row",
spacing: 24,
mediaItems: params.mediaResults.map((item, index) => ({
mediaItemId: `${params.command.requestId}:result-${index + 1}`,
type: item.nodeType || "image",
url: item.url,
assetId: item.assetId,
width: item.width,
height: item.height,
title: item.title,
prompt: item.prompt,
sourceNodeIds: [item.sourceNodeId],
metadata: {
...(item.metadata || {}),
canvasCapabilityId: params.command.capabilityId,
canvasCapabilityRequestId: params.command.requestId,
canvasCapabilitySource: params.command.source,
sourceNodeId: item.sourceNodeId,
},
})),
metadata: {
canvasCapabilityId: params.command.capabilityId,
canvasCapabilityRequestId: params.command.requestId,
canvasCapabilitySource: params.command.source,
canvasCapabilityVersion: params.command.version,
},
} satisfies CanvasOperationPlan;
}
function buildMutationOperationPlan(params: {
command: CanvasCapabilityClientCommand;
projectId: string;
projectRevision: string;
}): CanvasOperationPlan | null {
const common = {
version: 1 as const,
operationId: params.command.requestId,
projectId: params.projectId,
baseCanvasRevision: params.projectRevision,
targetNodeIds: params.command.nodeIds,
sourceNodeIds: params.command.nodeIds,
metadata: {
canvasCapabilityId: params.command.capabilityId,
canvasCapabilityRequestId: params.command.requestId,
canvasCapabilitySource: params.command.source,
},
};
if (params.command.capabilityId === "image.rename") {
return {
...common,
operation: "rename_node",
textContent: String(params.command.args.title || "").trim(),
};
}
if (params.command.capabilityId === "image.note") {
return {
...common,
operation: "update_note",
textContent: String(params.command.args.text || "").trim(),
};
}
if (params.command.capabilityId === "image.delete") {
return {
...common,
operation: "delete_nodes",
confirmed: params.command.confirmed === true,
};
}
return null;
}
function summarizeOperation(operation: Awaited<ReturnType<typeof applyCanvasOperationPlan>>) {
return {
canvasRevision: operation.canvasRevision,
canvasUpdatedAt: operation.canvasUpdatedAt,
createdNodeIds: operation.createdNodeIds,
createdNodeIdsByMediaItemId: operation.createdNodeIdsByMediaItemId,
updatedNodeIds: operation.updatedNodeIds,
};
}
export async function executeCanvasCapabilityRuntime(params: {
command: CanvasCapabilityClientCommand;
projectId: string;
requestHeaders: Headers;
requestUrl: string;
userId: string;
}): Promise<CanvasCapabilityRuntimeResult> {
const command = createCanvasCapabilityClientCommand({
capabilityId: params.command.capabilityId,
nodeIds: params.command.nodeIds,
projectId: params.projectId,
requestId: params.command.requestId,
args: params.command.args,
source: params.command.source,
confirmed: params.command.confirmed,
createdAt: params.command.createdAt,
});
const capability = getCanvasCapability(command.capabilityId);
if (!capability) {
throw new CanvasCapabilityRuntimeError("画布能力不存在", 404);
}
if (!(await isCanvasCapabilityEnabled(capability))) {
throw new CanvasCapabilityRuntimeError(
`${capability.title}已在系统设置中关闭`,
403,
"CANVAS_CAPABILITY_DISABLED"
);
}
const context = await resolveCanvasCapabilityContext({
command,
projectId: params.projectId,
requiresRemoteImage: capability.requiresRemoteImage,
userId: params.userId,
});
let task = await createOrRecoverCanvasCapabilityTask({
command,
userId: params.userId,
projectId: params.projectId,
});
if (task.status === "succeeded") {
const storedResult =
task.result && typeof task.result === "object"
? (task.result as Omit<CanvasCapabilityRuntimeResult, "task">)
: ({ mode: "headless" } as const);
return { ...storedResult, task: serializeTask(task) };
}
if (task.status === "running") {
return {
mode: shouldExecuteCanvasCapabilityHeadlessly({ capability, command })
? "headless"
: "interactive",
task: serializeTask(task),
};
}
if (
task.status === "failed" &&
task.attempts >= MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
) {
throw new CanvasCapabilityRuntimeError(
task.error || "画布能力任务超过最大重试次数",
409,
"CANVAS_CAPABILITY_RETRY_EXHAUSTED"
);
}
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
capability,
command,
});
if (!shouldRunHeadlessly) {
task = await markCanvasCapabilityTaskRunning(task.id);
const result = {
mode: "interactive" as const,
clientCommand: command,
};
task = (await markCanvasCapabilityTaskSucceeded(task.id, result)) || task;
return { ...result, task: serializeTask(task) };
}
return enqueueCanvasCapabilityExecution({
projectId: params.projectId,
execute: async () => {
task = await markCanvasCapabilityTaskRunning(task.id);
try {
const projectRevision = context.project.updatedAt.toISOString();
const mutationPlan = buildMutationOperationPlan({
command,
projectId: params.projectId,
projectRevision,
});
const mediaResults = mutationPlan
? []
: await executeCanvasCapabilityHeadlessly({
command,
requestHeaders: params.requestHeaders,
requestUrl: params.requestUrl,
targets: context.targets,
userId: params.userId,
});
const operation = await applyCanvasOperationPlan({
userId: params.userId,
projectId: params.projectId,
plan:
mutationPlan ||
buildMediaOperationPlan({
command,
mediaResults,
projectId: params.projectId,
projectRevision,
}),
});
const result = {
mode: "headless" as const,
operation: summarizeOperation(operation),
outputs: mediaResults.map((item) => ({
assetId: item.assetId,
sourceNodeId: item.sourceNodeId,
title: item.title,
url: item.url,
})),
};
task = (await markCanvasCapabilityTaskSucceeded(task.id, result)) || task;
return { ...result, task: serializeTask(task) };
} catch (error) {
task = (await markCanvasCapabilityTaskFailed(task.id, error)) || task;
throw error;
}
},
});
}

View File

@ -0,0 +1,34 @@
type CanvasCapabilityQueueState = {
tails: Map<string, Promise<unknown>>;
};
declare global {
var __canvasCapabilityQueueState: CanvasCapabilityQueueState | undefined;
}
function getQueueState() {
if (!globalThis.__canvasCapabilityQueueState) {
globalThis.__canvasCapabilityQueueState = { tails: new Map() };
}
return globalThis.__canvasCapabilityQueueState;
}
export async function enqueueCanvasCapabilityExecution<T>(params: {
projectId: string;
execute: () => Promise<T>;
}): Promise<T> {
const state = getQueueState();
const previous = state.tails.get(params.projectId) || Promise.resolve();
const execution = previous.catch(() => undefined).then(params.execute);
const tail = execution.finally(() => {
if (state.tails.get(params.projectId) === tail) {
state.tails.delete(params.projectId);
}
});
state.tails.set(params.projectId, tail);
return execution;
}
export function getCanvasCapabilityQueueDepth(projectId: string) {
return getQueueState().tails.has(projectId) ? 1 : 0;
}

View File

@ -0,0 +1,161 @@
import { and, eq, lt, sql } from "drizzle-orm";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { db } from "@/lib/db";
import { canvasCapabilityTask } from "@/lib/db/schema";
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
export const MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS = 3;
export type CanvasCapabilityTaskResult = Record<string, unknown>;
export async function getCanvasCapabilityTask(params: {
requestId: string;
userId: string;
projectId: string;
}) {
const [task] = await db
.select()
.from(canvasCapabilityTask)
.where(
and(
eq(canvasCapabilityTask.requestId, params.requestId),
eq(canvasCapabilityTask.userId, params.userId),
eq(canvasCapabilityTask.projectId, params.projectId)
)
)
.limit(1);
return task || null;
}
export async function createOrRecoverCanvasCapabilityTask(params: {
command: CanvasCapabilityClientCommand;
userId: string;
projectId: string;
}) {
await db
.insert(canvasCapabilityTask)
.values({
requestId: params.command.requestId,
userId: params.userId,
projectId: params.projectId,
capabilityId: params.command.capabilityId,
source: params.command.source,
request: params.command,
})
.onConflictDoNothing({ target: canvasCapabilityTask.requestId });
const taskLookup = {
requestId: params.command.requestId,
userId: params.userId,
projectId: params.projectId,
};
let task = await getCanvasCapabilityTask(taskLookup);
if (!task) {
throw new Error("无法创建画布能力任务");
}
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
if (
task.status === "failed" &&
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
) {
await db
.update(canvasCapabilityTask)
.set({
status: "pending",
error: null,
completedAt: null,
updatedAt: new Date(),
})
.where(eq(canvasCapabilityTask.id, task.id));
} else if (
task.status === "running" &&
task.updatedAt < staleBefore &&
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
) {
await db
.update(canvasCapabilityTask)
.set({
status: "pending",
error: "恢复超时的执行中任务",
updatedAt: new Date(),
})
.where(
and(
eq(canvasCapabilityTask.id, task.id),
lt(canvasCapabilityTask.updatedAt, staleBefore)
)
);
} else if (task.status === "running" && task.updatedAt < staleBefore) {
await db
.update(canvasCapabilityTask)
.set({
status: "failed",
error: "画布能力任务超过最大重试次数",
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(canvasCapabilityTask.id, task.id));
}
task = (await getCanvasCapabilityTask(taskLookup)) || task;
return task;
}
export async function markCanvasCapabilityTaskRunning(taskId: string) {
const now = new Date();
const [task] = await db
.update(canvasCapabilityTask)
.set({
status: "running",
attempts: sql`${canvasCapabilityTask.attempts} + 1`,
startedAt: now,
completedAt: null,
error: null,
updatedAt: now,
})
.where(eq(canvasCapabilityTask.id, taskId))
.returning();
if (!task) throw new Error("画布能力任务不存在");
return task;
}
export async function markCanvasCapabilityTaskSucceeded(
taskId: string,
result: CanvasCapabilityTaskResult
) {
const now = new Date();
const [task] = await db
.update(canvasCapabilityTask)
.set({
status: "succeeded",
result,
error: null,
completedAt: now,
updatedAt: now,
})
.where(eq(canvasCapabilityTask.id, taskId))
.returning();
return task || null;
}
export async function markCanvasCapabilityTaskFailed(
taskId: string,
error: unknown
) {
const now = new Date();
const message = error instanceof Error ? error.message : String(error || "执行失败");
const [task] = await db
.update(canvasCapabilityTask)
.set({
status: "failed",
error: message.slice(0, 4000),
completedAt: now,
updatedAt: now,
})
.where(eq(canvasCapabilityTask.id, taskId))
.returning();
return task || null;
}

View File

@ -0,0 +1,138 @@
import type { z } from "zod";
export const CANVAS_CAPABILITY_IDS = [
"image.add_to_chat",
"image.pick_object",
"image.enhance",
"image.remove_background",
"image.ai_wash",
"image.text_edit",
"image.box_cutout",
"image.local_edit",
"image.scale_adjust",
"image.remove_watermark",
"image.resize",
"image.multi_angle",
"image.generate_3d_model",
"image.generate_3d_world",
"image.layer_split",
"image.extend",
"image.crop",
"image.adjust",
"image.flip_rotate",
"image.prompt_regenerate",
"image.note",
"image.rename",
"image.download",
"image.delete",
] as const;
export type CanvasCapabilityId = (typeof CANVAS_CAPABILITY_IDS)[number];
export type CanvasCapabilityClientAction =
| "add-to-chat"
| "pick-object"
| "enhance"
| "rmbg"
| "ai-wash"
| "text-edit"
| "box-cutout"
| "local-edit"
| "scale-adjust"
| "watermark"
| "resize"
| "angle"
| "generate-3d-model"
| "generate-3d-world"
| "layer-split"
| "extend"
| "crop"
| "adjust"
| "flip-rotate"
| "prompt-regenerate"
| "note"
| "rename"
| "download"
| "delete";
export type SelectedImageToolbarActionKey =
| "pickObject"
| "enhance"
| "removeBackground"
| "aiWashQuick"
| "textEdit"
| "boxCutout"
| "localEdit"
| "removeWatermark"
| "resize"
| "multiAngle"
| "generate3dModel"
| "generate3dWorld"
| "layerSplit"
| "extend"
| "crop"
| "adjust"
| "flipRotate"
| "promptRegenerate"
| "note";
export type CanvasCapabilityExecutionMode =
| "headless"
| "interactive"
| "hybrid"
| "local";
export type CanvasCapabilityRisk = "read" | "write" | "external" | "destructive";
export type CanvasCapabilityModelPolicy =
| "none"
| "agent-selected"
| "feature-setting"
| "user-selected";
export type CanvasCapabilityCommandSource = "toolbar" | "agent" | "mcp" | "system";
export interface CanvasCapabilityDefinition<TSchema extends z.ZodTypeAny = z.ZodTypeAny> {
id: CanvasCapabilityId;
title: string;
description: string;
clientAction: CanvasCapabilityClientAction;
toolbarActionKey?: SelectedImageToolbarActionKey;
toolbarSettingKey?: string;
executionMode: CanvasCapabilityExecutionMode;
risk: CanvasCapabilityRisk;
modelPolicy: CanvasCapabilityModelPolicy;
modelSettingKey?: string;
requiresRemoteImage: boolean;
selection: {
nodeTypes: readonly ["image"];
min: number;
max?: number;
};
confirmation: "never" | "interactive" | "always";
agent: {
exposed: boolean;
toolName?: string;
aliases: readonly string[];
};
mcp: {
exposed: boolean;
toolName: string;
};
inputSchema: TSchema;
}
export interface CanvasCapabilityManifestEntry {
id: CanvasCapabilityId;
title: string;
description: string;
clientAction: CanvasCapabilityClientAction;
toolbarActionKey?: SelectedImageToolbarActionKey;
toolbarSettingKey?: string;
executionMode: CanvasCapabilityExecutionMode;
risk: CanvasCapabilityRisk;
modelPolicy: CanvasCapabilityModelPolicy;
modelSettingKey?: string;
requiresRemoteImage: boolean;
selection: CanvasCapabilityDefinition["selection"];
confirmation: CanvasCapabilityDefinition["confirmation"];
agent: CanvasCapabilityDefinition["agent"];
mcp: CanvasCapabilityDefinition["mcp"];
}

View File

@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS "CanvasCapabilityTask" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"requestId" varchar(180) NOT NULL UNIQUE,
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"projectId" uuid NOT NULL REFERENCES "CanvasProject"("id") ON DELETE CASCADE,
"capabilityId" varchar(96) NOT NULL,
"source" varchar(16) NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'pending',
"version" integer NOT NULL DEFAULT 1,
"request" jsonb NOT NULL,
"result" jsonb,
"error" text,
"attempts" integer NOT NULL DEFAULT 0,
"startedAt" timestamp,
"completedAt" timestamp,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "CanvasCapabilityTask_project_status_idx"
ON "CanvasCapabilityTask"("projectId", "status", "createdAt");
CREATE INDEX IF NOT EXISTS "CanvasCapabilityTask_user_created_idx"
ON "CanvasCapabilityTask"("userId", "createdAt");

View File

@ -540,6 +540,13 @@
"when": 1783946203000, "when": 1783946203000,
"tag": "0090_add_bananaimage_provider_type", "tag": "0090_add_bananaimage_provider_type",
"breakpoints": true "breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1783936800000,
"tag": "0091_add_canvas_capability_tasks",
"breakpoints": true
} }
] ]
} }

View File

@ -947,6 +947,31 @@ export const canvasImageTask = pgTable("CanvasImageTask", {
export type CanvasImageTask = InferSelectModel<typeof canvasImageTask>; export type CanvasImageTask = InferSelectModel<typeof canvasImageTask>;
export const canvasCapabilityTask = pgTable("CanvasCapabilityTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
requestId: varchar("requestId", { length: 180 }).notNull().unique(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
projectId: uuid("projectId")
.notNull()
.references(() => canvasProject.id, { onDelete: "cascade" }),
capabilityId: varchar("capabilityId", { length: 96 }).notNull(),
source: varchar("source", { length: 16 }).notNull(),
status: varchar("status", { length: 16 }).notNull().default("pending"),
version: integer("version").notNull().default(1),
request: jsonb("request").notNull(),
result: jsonb("result"),
error: text("error"),
attempts: integer("attempts").notNull().default(0),
startedAt: timestamp("startedAt"),
completedAt: timestamp("completedAt"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type CanvasCapabilityTask = InferSelectModel<typeof canvasCapabilityTask>;
export const enhanceProTask = pgTable("EnhanceProTask", { export const enhanceProTask = pgTable("EnhanceProTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(), id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId") userId: uuid("userId")

View File

@ -76,6 +76,7 @@
"@icons-pack/react-simple-icons": "^13.7.0", "@icons-pack/react-simple-icons": "^13.7.0",
"@langchain/core": "^1.1.19", "@langchain/core": "^1.1.19",
"@langchain/langgraph": "^1.1.3", "@langchain/langgraph": "^1.1.3",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.200.0", "@opentelemetry/api-logs": "^0.200.0",
"@plait-board/markdown-to-drawnix": "0.0.7", "@plait-board/markdown-to-drawnix": "0.0.7",

548
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,19 @@ import {
normalizeStyleProPersistedState, normalizeStyleProPersistedState,
type StyleProPersistedState, type StyleProPersistedState,
} from '@/lib/style-pro/config'; } from '@/lib/style-pro/config';
import {
canvasCapabilityClientCommandSchema,
createCanvasCapabilityClientCommand,
type CanvasCapabilityClientCommand,
} from '@/lib/canvas/capabilities/client-command';
import {
getCanvasCapability,
getCanvasCapabilityByClientAction,
} from '@/lib/canvas/capabilities/registry';
import type {
CanvasCapabilityClientAction,
CanvasCapabilityCommandSource,
} from '@/lib/canvas/capabilities/types';
// 生成唯一 ID // 生成唯一 ID
const generateId = () => crypto.randomUUID(); const generateId = () => crypto.randomUUID();
@ -73,12 +86,17 @@ export interface PickedObject {
}; };
} }
export type ImageNodeActionType = 'box-cutout' | 'text-edit' | 'local-edit'; export type ImageNodeActionType = CanvasCapabilityClientAction;
export interface ImageNodeActionRequest { export interface ImageNodeActionRequest extends CanvasCapabilityClientCommand {
nodeId: string; nodeId: string;
action: ImageNodeActionType; action: ImageNodeActionType;
requestId: string; }
export interface ImageNodeActionOptions {
args?: unknown;
source?: CanvasCapabilityCommandSource;
confirmed?: boolean;
} }
type CanvasViewportSource = 'user' | 'agent-transient'; type CanvasViewportSource = 'user' | 'agent-transient';
@ -120,6 +138,8 @@ interface CanvasStore {
pickedObject: PickedObject | null; pickedObject: PickedObject | null;
pendingPickedObjects: PickedObject[]; pendingPickedObjects: PickedObject[];
imageNodeActionRequest: ImageNodeActionRequest | null; imageNodeActionRequest: ImageNodeActionRequest | null;
imageNodeActionQueue: ImageNodeActionRequest[];
handledCanvasCapabilityRequestIds: string[];
// 🆕 新增节点(用于自动聚焦) // 🆕 新增节点(用于自动聚焦)
lastAddedNodeId: string | null; lastAddedNodeId: string | null;
@ -193,7 +213,14 @@ interface CanvasStore {
setPickedObject: (obj: PickedObject | null) => void; setPickedObject: (obj: PickedObject | null) => void;
addPendingPickedObject: (obj: PickedObject) => void; addPendingPickedObject: (obj: PickedObject) => void;
clearPendingPickedObjects: () => void; clearPendingPickedObjects: () => void;
requestImageNodeAction: (nodeId: string, action: ImageNodeActionType) => void; dispatchCanvasCapabilityCommand: (
command: CanvasCapabilityClientCommand
) => void;
requestImageNodeAction: (
nodeId: string,
action: ImageNodeActionType,
options?: ImageNodeActionOptions
) => void;
clearImageNodeActionRequest: () => void; clearImageNodeActionRequest: () => void;
// 🆕 自动聚焦控制 // 🆕 自动聚焦控制
@ -236,11 +263,18 @@ export const useCanvasStore = create<CanvasStore>()(
pickedObject: null, pickedObject: null,
pendingPickedObjects: [], pendingPickedObjects: [],
imageNodeActionRequest: null, imageNodeActionRequest: null,
imageNodeActionQueue: [],
handledCanvasCapabilityRequestIds: [],
lastAddedNodeId: null, lastAddedNodeId: null,
agentOperationRefreshUntil: 0, agentOperationRefreshUntil: 0,
// 项目操作 // 项目操作
setProject: (id, title) => set((state) => { setProject: (id, title) => set((state) => {
if (state.projectId !== id) {
state.imageNodeActionRequest = null;
state.imageNodeActionQueue = [];
state.handledCanvasCapabilityRequestIds = [];
}
state.projectId = id; state.projectId = id;
if (title) state.projectTitle = title; if (title) state.projectTitle = title;
}), }),
@ -632,6 +666,9 @@ export const useCanvasStore = create<CanvasStore>()(
state.canvasUpdatedAt = null; state.canvasUpdatedAt = null;
state.suppressedMediaKeys = []; state.suppressedMediaKeys = [];
state.styleProState = { history: [] }; state.styleProState = { history: [] };
state.imageNodeActionRequest = null;
state.imageNodeActionQueue = [];
state.handledCanvasCapabilityRequestIds = [];
state.history = []; state.history = [];
state.historyIndex = -1; state.historyIndex = -1;
}), }),
@ -725,16 +762,85 @@ export const useCanvasStore = create<CanvasStore>()(
state.pendingPickedObjects = []; state.pendingPickedObjects = [];
}), }),
requestImageNodeAction: (nodeId, action) => set((state) => { dispatchCanvasCapabilityCommand: (command) => set((state) => {
state.imageNodeActionRequest = { const envelope = canvasCapabilityClientCommandSchema.parse(command);
if (
state.handledCanvasCapabilityRequestIds.includes(envelope.requestId)
) {
return;
}
const parsedCommand = createCanvasCapabilityClientCommand({
capabilityId: envelope.capabilityId,
nodeIds: envelope.nodeIds,
projectId: envelope.projectId,
requestId: envelope.requestId,
args: envelope.args,
source: envelope.source,
confirmed: envelope.confirmed,
createdAt: envelope.createdAt,
});
const capability = getCanvasCapability(parsedCommand.capabilityId);
if (!capability) return;
const availableImageNodeIds = new Set(
state.nodes
.filter((node) => node.type === 'image')
.map((node) => node.id)
);
const requests = parsedCommand.nodeIds
.filter((nodeId) => availableImageNodeIds.has(nodeId))
.map((nodeId, index) => ({
...parsedCommand,
requestId:
parsedCommand.nodeIds.length > 1
? `${parsedCommand.requestId}:${index + 1}`
: parsedCommand.requestId,
nodeIds: [nodeId],
nodeId,
action: capability.clientAction,
}));
const firstRequest = requests.shift();
if (!firstRequest) return;
state.handledCanvasCapabilityRequestIds.push(envelope.requestId);
if (state.handledCanvasCapabilityRequestIds.length > 200) {
state.handledCanvasCapabilityRequestIds.splice(
0,
state.handledCanvasCapabilityRequestIds.length - 200
);
}
if (state.imageNodeActionRequest) {
state.imageNodeActionQueue.push(firstRequest, ...requests);
} else {
state.imageNodeActionRequest = firstRequest;
state.imageNodeActionQueue.push(...requests);
}
}),
requestImageNodeAction: (nodeId, action, options) => set((state) => {
const capability = getCanvasCapabilityByClientAction(action);
if (!capability) return;
const command = createCanvasCapabilityClientCommand({
capabilityId: capability.id,
nodeIds: [nodeId],
projectId: state.projectId || undefined,
args: options?.args,
source: options?.source || 'toolbar',
confirmed: options?.confirmed,
});
const request = {
...command,
nodeId, nodeId,
action, action,
requestId: generateId(),
}; };
if (state.imageNodeActionRequest) {
state.imageNodeActionQueue.push(request);
} else {
state.imageNodeActionRequest = request;
}
}), }),
clearImageNodeActionRequest: () => set((state) => { clearImageNodeActionRequest: () => set((state) => {
state.imageNodeActionRequest = null; state.imageNodeActionRequest = state.imageNodeActionQueue.shift() || null;
}), }),
clearLastAddedNodeId: () => set((state) => { clearLastAddedNodeId: () => set((state) => {

View File

@ -0,0 +1,291 @@
import { expect, test } from "@playwright/test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { applyCanvasOperationPlanToCanvasData } from "@/lib/canvas/agent-mutations";
import {
CANVAS_CAPABILITY_REGISTRY,
getCanvasCapability,
getCanvasCapabilityManifest,
} from "@/lib/canvas/capabilities/registry";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { shouldExecuteCanvasCapabilityHeadlessly } from "@/lib/canvas/capabilities/execution-mode";
import { createCanvasCapabilityMcpServer } from "@/lib/canvas/capabilities/mcp-server";
import { CANVAS_CAPABILITY_IDS } from "@/lib/canvas/capabilities/types";
import { useCanvasStore } from "@/store/canvas";
const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
"pickObject",
"enhance",
"removeBackground",
"aiWashQuick",
"textEdit",
"boxCutout",
"localEdit",
"removeWatermark",
"resize",
"multiAngle",
"generate3dModel",
"generate3dWorld",
"layerSplit",
"extend",
"crop",
"adjust",
"flipRotate",
"promptRegenerate",
"note",
] as const;
test.describe("canvas capability runtime contracts", () => {
test("registers every selected-image toolbar capability exactly once", () => {
const registeredActions = CANVAS_CAPABILITY_REGISTRY.flatMap((capability) =>
capability.toolbarActionKey ? [capability.toolbarActionKey] : []
);
expect(new Set(registeredActions).size).toBe(registeredActions.length);
expect(registeredActions.sort()).toEqual([...SELECTED_IMAGE_TOOLBAR_ACTIONS].sort());
});
test("keeps capability, client action, Agent, and MCP names unique", () => {
const ids = CANVAS_CAPABILITY_REGISTRY.map((capability) => capability.id);
const clientActions = CANVAS_CAPABILITY_REGISTRY.map(
(capability) => capability.clientAction
);
const agentTools = CANVAS_CAPABILITY_REGISTRY.flatMap((capability) =>
capability.agent.exposed && capability.agent.toolName
? [capability.agent.toolName]
: []
);
const mcpTools = CANVAS_CAPABILITY_REGISTRY.flatMap((capability) =>
capability.mcp.exposed ? [capability.mcp.toolName] : []
);
expect(ids).toEqual([...CANVAS_CAPABILITY_IDS]);
expect(new Set(ids).size).toBe(ids.length);
expect(new Set(clientActions).size).toBe(clientActions.length);
expect(new Set(agentTools).size).toBe(agentTools.length);
expect(new Set(mcpTools).size).toBe(mcpTools.length);
});
test("validates arguments and preserves execution provenance", () => {
const command = createCanvasCapabilityClientCommand({
capabilityId: "image.enhance",
nodeIds: ["image-a", "image-a", "image-b"],
projectId: "canvas-1",
requestId: "request-1",
args: { scaleFactor: 4 },
source: "agent",
createdAt: "2026-07-13T00:00:00.000Z",
});
expect(command).toMatchObject({
capabilityId: "image.enhance",
nodeIds: ["image-a", "image-b"],
projectId: "canvas-1",
requestId: "request-1",
args: { scaleFactor: 4 },
source: "agent",
});
expect(() =>
createCanvasCapabilityClientCommand({
capabilityId: "image.enhance",
nodeIds: ["image-a"],
args: { scaleFactor: 12 },
source: "agent",
})
).toThrow();
});
test("requires explicit confirmation for destructive capabilities", () => {
expect(() =>
createCanvasCapabilityClientCommand({
capabilityId: "image.delete",
nodeIds: ["image-a"],
source: "agent",
})
).toThrow("requires explicit confirmation");
expect(
createCanvasCapabilityClientCommand({
capabilityId: "image.delete",
nodeIds: ["image-a"],
source: "agent",
confirmed: true,
}).confirmed
).toBe(true);
});
test("publishes a serializable manifest without runtime schemas", () => {
const manifest = getCanvasCapabilityManifest();
expect(manifest).toHaveLength(CANVAS_CAPABILITY_IDS.length);
expect(manifest.every((entry) => !("inputSchema" in entry))).toBe(true);
expect(() => JSON.stringify(manifest)).not.toThrow();
});
test("exposes the registry through a real MCP client", async () => {
const server = await createCanvasCapabilityMcpServer({
filterDisabled: false,
projectId: "canvas-1",
requestHeaders: new Headers(),
requestUrl: "http://localhost/api/mcp/canvas/canvas-1",
userId: "user-1",
});
const client = new Client({ name: "capability-contract-test", version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const result = await client.listTools();
const expectedNames = CANVAS_CAPABILITY_REGISTRY.flatMap((capability) =>
capability.mcp.exposed && capability.mcp.toolName
? [capability.mcp.toolName]
: []
);
expect(result.tools.map((tool) => tool.name).sort()).toEqual(
expectedNames.sort()
);
expect(
result.tools.every(
(tool) =>
tool.inputSchema.type === "object" &&
Object.hasOwn(tool.inputSchema.properties || {}, "nodeIds")
)
).toBe(true);
} finally {
await client.close();
await server.close();
}
});
test("runs 3D capabilities headlessly for Agent and MCP", () => {
for (const capabilityId of [
"image.generate_3d_model",
"image.generate_3d_world",
] as const) {
const capability = getCanvasCapability(capabilityId);
expect(capability).not.toBeNull();
if (!capability) continue;
const agentCommand = createCanvasCapabilityClientCommand({
capabilityId,
nodeIds: ["image-a"],
source: "agent",
});
const toolbarCommand = createCanvasCapabilityClientCommand({
capabilityId,
nodeIds: ["image-a"],
source: "toolbar",
});
expect(
shouldExecuteCanvasCapabilityHeadlessly({ capability, command: agentCommand })
).toBe(true);
expect(
shouldExecuteCanvasCapabilityHeadlessly({ capability, command: toolbarCommand })
).toBe(false);
}
});
test("writes recoverable 3D task nodes through the shared mutation protocol", () => {
const sourceNode = {
id: "image-a",
type: "image",
position: { x: 10, y: 20 },
size: { width: 320, height: 240 },
data: { src: "https://example.com/source.png" },
};
const cases = [
{ type: "model3d" as const, provider: "tripo-3d" },
{ type: "world3d" as const, provider: "worldlabs-3d" },
];
for (const item of cases) {
const result = applyCanvasOperationPlanToCanvasData({
canvasData: { nodes: [sourceNode], edges: [] },
plan: {
version: 1,
operationId: `operation-${item.type}`,
operation: "insert_beside",
projectId: "canvas-1",
sourceNodeIds: [sourceNode.id],
targetNodeIds: [sourceNode.id],
placement: "right",
placementStrategy: "beside_each_source",
mediaItems: [
{
mediaItemId: `result-${item.type}`,
type: item.type,
url: "https://example.com/source.png",
title: `3D result ${item.type}`,
sourceNodeIds: [sourceNode.id],
metadata: {
taskId: `task-${item.type}`,
provider: item.provider,
status: "generating",
},
},
],
},
});
const resultNodes = (Array.isArray(result.canvasData.nodes)
? result.canvasData.nodes
: []) as Array<{
connections?: string[];
data?: { metadata?: { taskId?: string }; status?: string };
id: string;
type?: string;
}>;
const createdNode = resultNodes.find(
(node) => node.id === result.createdNodeIds[0]
);
expect(createdNode?.type).toBe(item.type);
expect(createdNode?.data?.status).toBe("generating");
expect(createdNode?.data?.metadata?.taskId).toBe(`task-${item.type}`);
expect(createdNode?.connections).toContain(sourceNode.id);
}
});
test("queues client commands once and clears them when switching canvas", () => {
useCanvasStore.setState({
projectId: "canvas-1",
nodes: [
{
id: "image-a",
type: "image",
position: { x: 0, y: 0 },
size: { width: 320, height: 320 },
data: {
src: "https://example.com/source.png",
status: "completed",
},
connections: [],
createdAt: new Date(),
updatedAt: new Date(),
},
],
imageNodeActionRequest: null,
imageNodeActionQueue: [],
handledCanvasCapabilityRequestIds: [],
});
const command = createCanvasCapabilityClientCommand({
capabilityId: "image.crop",
nodeIds: ["image-a"],
projectId: "canvas-1",
requestId: "interactive-request-1",
source: "agent",
createdAt: "2026-07-13T00:00:00.000Z",
});
useCanvasStore.getState().dispatchCanvasCapabilityCommand(command);
useCanvasStore.getState().dispatchCanvasCapabilityCommand(command);
expect(useCanvasStore.getState().imageNodeActionRequest?.requestId).toBe(
command.requestId
);
expect(useCanvasStore.getState().imageNodeActionQueue).toHaveLength(0);
useCanvasStore.getState().setProject("canvas-2");
expect(useCanvasStore.getState().imageNodeActionRequest).toBeNull();
expect(useCanvasStore.getState().handledCanvasCapabilityRequestIds).toEqual([]);
});
});

View File

@ -126,7 +126,7 @@ export type CanvasOperationKind =
export interface CanvasMediaItem { export interface CanvasMediaItem {
mediaItemId: string; mediaItemId: string;
type: "image" | "video"; type: "image" | "video" | "model3d" | "world3d";
url: string; url: string;
assetId?: string; assetId?: string;
width?: number; width?: number;