diff --git a/app/(chat)/api/chat/_lib/stream.ts b/app/(chat)/api/chat/_lib/stream.ts index c0abecf59..0db0508f0 100644 --- a/app/(chat)/api/chat/_lib/stream.ts +++ b/app/(chat)/api/chat/_lib/stream.ts @@ -7,6 +7,7 @@ import type { ResolvedTargetAssets, } from "@/lib/ai/context-memory/types"; import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol"; +import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import { generateUUID } from "@/lib/utils"; 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 = { url: string; title?: string; diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index ad249794f..ce9e69e9a 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -90,6 +90,10 @@ import { hasCanvasAgentPanViewIntent, } from "@/lib/canvas/agent-view-pan"; import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback"; +import { + createCanvasCapabilityTools, + getCanvasCapabilityToolNames, +} from "@/lib/canvas/capabilities/ai-sdk-tools"; import { getCanvasAgentOperationErrorMessage, normalizeCanvasAgentOperationErrorCode, @@ -392,6 +396,7 @@ import { writeAgentTargetLockChunk, writeAssistantTextChunk, writeCanvasMediaChunk, + writeCanvasCapabilityCommandChunk, writeCanvasOperationChunk, writeCanvasViewOperationChunk, writeImageGenerationStatusChunk, @@ -7716,6 +7721,17 @@ export async function POST(request: Request) { const directAgentExecution = directAgentToolContext?.execution || null; const directAgentToolTargetImageUrls = directAgentToolContext?.targetImageUrls || []; 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 ( shouldRunAgentConversation && @@ -8265,6 +8281,7 @@ export async function POST(request: Request) { shouldRunAgentConversation && !smartAutoNeedsClarification && directAgentExecution && + !shouldUseCanvasCapabilityToolLoop && isDirectAgentImageEditToolName(directAgentExecution.toolName) ) { const directAgentToolCapability = directAgentExecution.capability; @@ -8711,6 +8728,14 @@ export async function POST(request: Request) { agentIdentityInstruction, recentDialogueSystemContext, baseConversationSystemPrompt, + shouldUseCanvasCapabilityToolLoop + ? [ + "当前请求来自无限画布,且用户已明确选中图片节点。", + "涉及选中图片的识别、编辑、生成、3D、改名、批注或删除时,必须调用已提供的画布能力工具,不得只用文字声称完成。", + "可以连续调用多个工具;下一步要处理上一步结果时,把上一步返回的 createdNodeIds 作为 nodeIds。", + "只有工具返回成功后才能告诉用户已完成;若工具返回 interactive,说明已在画布打开对应交互界面并等待用户操作。", + ].join("\n") + : "", ] .filter(Boolean) .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)"); } + 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] Provider type:", (languageModel as any).provider); // 调试日志 @@ -11938,9 +12009,13 @@ export async function POST(request: Request) { model: languageModel, system: effectiveConversationSystemPrompt, messages: convertedMessages, - stopWhen: stepCountIs(5), + stopWhen: stepCountIs(shouldUseCanvasCapabilityToolLoop ? 8 : 5), experimental_activeTools: - finalModelId === "chat-model-reasoning" || isVolcanoVideoModel ? [] : activeTools, + (finalModelId === "chat-model-reasoning" && + !shouldUseCanvasCapabilityToolLoop) || + isVolcanoVideoModel + ? [] + : activeTools, experimental_transform: shouldDisableSmoothStream ? undefined : smoothStream({ chunking: "word" }), diff --git a/app/api/canvas/[id]/capabilities/route.ts b/app/api/canvas/[id]/capabilities/route.ts new file mode 100644 index 000000000..9507c77d9 --- /dev/null +++ b/app/api/canvas/[id]/capabilities/route.ts @@ -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 } + ); + } +} diff --git a/app/api/mcp/canvas/[id]/route.ts b/app/api/mcp/canvas/[id]/route.ts new file mode 100644 index 000000000..91b36b008 --- /dev/null +++ b/app/api/mcp/canvas/[id]/route.ts @@ -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; diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 3eba73b65..5a8000c56 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -151,6 +151,7 @@ import { type AgentTargetLockStreamPayload, buildAgentProgressNodeSteps, type CanvasOperationStreamPayload, + type CanvasCapabilityCommandStreamPayload, type CanvasViewOperationStreamPayload, type CanvasMediaStreamPayload, filterCanvasMediaPayloadForUnhandledItems, @@ -165,6 +166,7 @@ import { shouldRetryCanvasViewOperationAfterRefresh, shouldSkipCanvasMediaPayloadForHandledItems, } from "@/components/canvas/chat-panel/canvas-agent-stream"; +import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command"; import { mergePendingCanvasAgentTargetNodeIds, resolvePendingCanvasAgentTargetNode, @@ -3232,6 +3234,9 @@ export function CanvasChatPanel({ // 🆕 物品拾取 const pendingPickedObjects = useCanvasStore((state) => state.pendingPickedObjects); const clearPendingPickedObjects = useCanvasStore((state) => state.clearPendingPickedObjects); + const dispatchCanvasCapabilityCommand = useCanvasStore( + (state) => state.dispatchCanvasCapabilityCommand + ); const clearLatestGeneratedImageHighlights = useCallback(() => { const currentNodes = useCanvasStore.getState().nodes; @@ -6626,6 +6631,24 @@ export function CanvasChatPanel({ 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") { try { const payload: CanvasOperationStreamPayload = diff --git a/components/canvas/chat-panel/canvas-agent-stream.ts b/components/canvas/chat-panel/canvas-agent-stream.ts index e7a6246a2..4a66d17f1 100644 --- a/components/canvas/chat-panel/canvas-agent-stream.ts +++ b/components/canvas/chat-panel/canvas-agent-stream.ts @@ -1,4 +1,7 @@ import type { CanvasViewOperationPlan } from "@/types/canvas/agent-protocol"; +import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; + +export type CanvasCapabilityCommandStreamPayload = CanvasCapabilityClientCommand; export type CanvasMediaStreamPayload = { messageId?: string; diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 7a354d82c..21e70853b 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -220,6 +220,12 @@ import { } from "@/lib/ai-wash/config"; import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors"; 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 type { CanvasTextEditPayloadItem, @@ -282,16 +288,6 @@ interface CanvasNodeAnnotationDisplayItem { 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([ "ai-wash", "rmbg", @@ -1477,7 +1473,10 @@ export function ImageNode({ isLayerSplitEnabled && imageLayerSplitEnabledByToolbar; const selectedImageInlineActionCount = getSelectedImageInlineActionCount(canvasToolbarConfig); - const selectedImageToolbarActionCandidates = [ + const selectedImageToolbarActionCandidates: ReadonlyArray<{ + key: SelectedImageToolbarActionKey; + enabled: boolean; + }> = [ { key: "pickObject", enabled: imagePickObjectEnabled }, { key: "enhance", enabled: imageEnhanceEnabled }, { key: "removeBackground", enabled: imageRemoveBackgroundEnabled }, @@ -1497,9 +1496,7 @@ export function ImageNode({ { key: "flipRotate", enabled: imageFlipRotateEnabled }, { key: "promptRegenerate", enabled: imagePromptRegenerateEnabled }, { key: "note", enabled: imageNoteEnabled }, - ] as const; - type SelectedImageToolbarActionKey = - (typeof selectedImageToolbarActionCandidates)[number]["key"]; + ]; const enabledSelectedImageToolbarActions = selectedImageToolbarActionCandidates.filter((action) => action.enabled); const selectedImageInlineActionKeys = new Set( @@ -2037,6 +2034,7 @@ export function ImageNode({ >({}); const localEditRegionsRef = useRef(localEditRegions); const hydratedImageTaskRecoveryRef = useRef(null); + const processedCapabilityCommandIdsRef = useRef(new Set()); const updateNode = useCanvasStore((state) => state.updateNode); const deleteNode = useCanvasStore((state) => state.deleteNode); @@ -2044,6 +2042,7 @@ export function ImageNode({ const selectNode = useCanvasStore((state) => state.selectNode); const projectId = useCanvasStore((state) => state.projectId); const getCanvasState = useCanvasStore((state) => state.getCanvasState); + const loadCanvas = useCanvasStore((state) => state.loadCanvas); const addPendingChatAttachment = useCanvasStore( (state) => state.addPendingChatAttachment ); @@ -6043,35 +6042,6 @@ export function ImageNode({ await detectTextItems(true); }, [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) => { setTextEditItems((prev) => prev.map((item) => @@ -6356,30 +6326,6 @@ export function ImageNode({ e.target.value = ""; }; - // 获取操作的中文名称 - const getActionName = (action: string): string => { - const names: Record = { - 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( (mode: "model" | "world") => { if (!data.src) { @@ -6642,8 +6588,55 @@ export function ImageNode({ updateNode, ]); - // 处理图片编辑操作 - 集成我们现有的 API - const handleAction = async (action: string) => { + const executeSharedToolbarCapability = async ( + 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) { toast.error("请先上传图片"); return; @@ -6746,34 +6739,7 @@ export function ImageNode({ toast.info("图层拆分功能未开启"); return; } - setIsCreatingDetachedResultNode(true); - 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); - } + await executeSharedToolbarCapability("layer-split"); return; } if (action === "box-cutout") { @@ -6835,102 +6801,13 @@ export function ImageNode({ return; } - const actionName = getActionName(action); - - setImageAdjustPanelOpen(false); - setCropPanelOpen(false); - setCropInteraction(null); - setFlipRotatePanelOpen(false); - setAnglePanelOpen(false); - 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); + if ( + action === "rmbg" || + action === "watermark" || + action === "enhance" || + action === "extend" + ) { + await executeSharedToolbarCapability(action); } }; @@ -6974,7 +6851,16 @@ export function ImageNode({ const action = customEvent.detail?.action; 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( @@ -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 ( annotatedImageUrl: string, @@ -8364,6 +8151,78 @@ export function ImageNode({ 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(() => { if (boxCutoutMetrics) { return { diff --git a/components/canvas/nodes/Model3DNode.tsx b/components/canvas/nodes/Model3DNode.tsx index e0b981a16..a915ae60a 100644 --- a/components/canvas/nodes/Model3DNode.tsx +++ b/components/canvas/nodes/Model3DNode.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, Box, @@ -43,6 +43,7 @@ import { type CanvasToolbarConfig, } from "@/lib/canvas-toolbar-config"; import { useCanvasStore } from "@/store/canvas"; +import { canvasService, model3DGenerationService } from "@/services/canvas"; import type { CanvasNode, Model3DNodeData } from "@/types/canvas"; import { cn, fetcher } from "@/lib/utils"; @@ -61,6 +62,15 @@ const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = { 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) { const raw = String(modelUrl || "").trim(); if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`; @@ -93,7 +103,12 @@ export function Model3DNode({ }: Model3DNodeProps) { const data = node.data as Model3DNodeData; const [isHovered, setIsHovered] = useState(false); + const recoveryAttemptRef = useRef(0); + const recoveryTaskRef = useRef(null); 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( canvasToolbarConfig, "model3dOpen3d" @@ -127,6 +142,17 @@ export function Model3DNode({ "model3dDelete" ); 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<{ success?: boolean; data?: PublicThreeDModelAliasConfig; @@ -199,6 +225,224 @@ export function Model3DNode({ [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) => { + 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 | 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 | 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) + : {}; + 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 = () => { if (!primaryOpenUrl) { toast.error("暂无可打开的地址"); diff --git a/docs/无限画布-Capability-Runtime与MCP接入说明.md b/docs/无限画布-Capability-Runtime与MCP接入说明.md new file mode 100644 index 000000000..563946d68 --- /dev/null +++ b/docs/无限画布-Capability-Runtime与MCP接入说明.md @@ -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 只传模型别名或能力参数,不持有服务商密钥。 diff --git a/lib/canvas/agent-mutations.ts b/lib/canvas/agent-mutations.ts index 24a927b5c..b646d23be 100644 --- a/lib/canvas/agent-mutations.ts +++ b/lib/canvas/agent-mutations.ts @@ -104,7 +104,10 @@ function findNode(nodes: any[], nodeId?: string) { } 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: { @@ -138,6 +141,13 @@ function buildMediaNode(params: { const primarySourceNodeId = String( itemMetadata.sourceNodeId || planMetadata.sourceNodeId || params.sourceNodeIds[0] || "" ).trim(); + const modelVersion = String( + itemMetadata.modelVersion || planMetadata.modelVersion || "" + ).trim(); + const taskStatus = + itemMetadata.status === "completed" || itemMetadata.status === "failed" + ? itemMetadata.status + : "generating"; const metadata = { ...planMetadata, ...itemMetadata, @@ -160,29 +170,57 @@ function buildMediaNode(params: { : undefined, }; + let data: Record; + 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 { id: generateId(), type, position: params.position, size: params.size, - 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, - }, + data, connections: params.sourceNodeIds, createdAt: now, updatedAt: now, diff --git a/lib/canvas/capabilities/ai-sdk-tools.ts b/lib/canvas/capabilities/ai-sdk-tools.ts new file mode 100644 index 000000000..ec5df3465 --- /dev/null +++ b/lib/canvas/capabilities/ai-sdk-tools.ts @@ -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, + 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); +} diff --git a/lib/canvas/capabilities/availability.ts b/lib/canvas/capabilities/availability.ts new file mode 100644 index 000000000..fadae8a7e --- /dev/null +++ b/lib/canvas/capabilities/availability.ts @@ -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) + ); +} diff --git a/lib/canvas/capabilities/client-command.ts b/lib/canvas/capabilities/client-command.ts new file mode 100644 index 000000000..2bb442778 --- /dev/null +++ b/lib/canvas/capabilities/client-command.ts @@ -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(), + }); +} diff --git a/lib/canvas/capabilities/context.ts b/lib/canvas/capabilities/context.ts new file mode 100644 index 000000000..1ef54c7e8 --- /dev/null +++ b/lib/canvas/capabilities/context.ts @@ -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; + 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) + : {}; + const nodes = Array.isArray(canvasData.nodes) ? canvasData.nodes : []; + const nodeById = new Map>( + nodes.map((node: Record) => [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, + }; +} diff --git a/lib/canvas/capabilities/execution-mode.ts b/lib/canvas/capabilities/execution-mode.ts new file mode 100644 index 000000000..3f8959b0b --- /dev/null +++ b/lib/canvas/capabilities/execution-mode.ts @@ -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) { + 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; + } +} diff --git a/lib/canvas/capabilities/headless-executor.ts b/lib/canvas/capabilities/headless-executor.ts new file mode 100644 index 000000000..ff85cde59 --- /dev/null +++ b/lib/canvas/capabilities/headless-executor.ts @@ -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; + 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; + 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; +} + +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; +} + +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 { + 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 | 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 | 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 | 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) + : {}; + 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; +} diff --git a/lib/canvas/capabilities/mcp-server.ts b/lib/canvas/capabilities/mcp-server.ts new file mode 100644 index 000000000..2b431a120 --- /dev/null +++ b/lib/canvas/capabilities/mcp-server.ts @@ -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; +} diff --git a/lib/canvas/capabilities/registry.ts b/lib/canvas/capabilities/registry.ts new file mode 100644 index 000000000..26b95b143 --- /dev/null +++ b/lib/canvas/capabilities/registry.ts @@ -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; + requiresRemoteImage?: boolean; + confirmation?: CanvasCapabilityDefinition["confirmation"]; + agent?: Partial; + mcp?: Partial; +}; + +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( + CANVAS_CAPABILITY_REGISTRY.map((capability) => [capability.id, capability]), +); +const capabilityByClientAction = new Map( + 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); +} diff --git a/lib/canvas/capabilities/runtime.ts b/lib/canvas/capabilities/runtime.ts new file mode 100644 index 000000000..b718adf6d --- /dev/null +++ b/lib/canvas/capabilities/runtime.ts @@ -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; + 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>) { + 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 { + 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) + : ({ 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; + } + }, + }); +} diff --git a/lib/canvas/capabilities/task-queue.ts b/lib/canvas/capabilities/task-queue.ts new file mode 100644 index 000000000..aaafdc786 --- /dev/null +++ b/lib/canvas/capabilities/task-queue.ts @@ -0,0 +1,34 @@ +type CanvasCapabilityQueueState = { + tails: Map>; +}; + +declare global { + var __canvasCapabilityQueueState: CanvasCapabilityQueueState | undefined; +} + +function getQueueState() { + if (!globalThis.__canvasCapabilityQueueState) { + globalThis.__canvasCapabilityQueueState = { tails: new Map() }; + } + return globalThis.__canvasCapabilityQueueState; +} + +export async function enqueueCanvasCapabilityExecution(params: { + projectId: string; + execute: () => Promise; +}): Promise { + 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; +} diff --git a/lib/canvas/capabilities/task-store.ts b/lib/canvas/capabilities/task-store.ts new file mode 100644 index 000000000..db96249bb --- /dev/null +++ b/lib/canvas/capabilities/task-store.ts @@ -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; + +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; +} diff --git a/lib/canvas/capabilities/types.ts b/lib/canvas/capabilities/types.ts new file mode 100644 index 000000000..c378d52ce --- /dev/null +++ b/lib/canvas/capabilities/types.ts @@ -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 { + 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"]; +} diff --git a/lib/db/migrations/0091_add_canvas_capability_tasks.sql b/lib/db/migrations/0091_add_canvas_capability_tasks.sql new file mode 100644 index 000000000..10ceecea9 --- /dev/null +++ b/lib/db/migrations/0091_add_canvas_capability_tasks.sql @@ -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"); diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 7689b3bbe..4b4c8c170 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -540,6 +540,13 @@ "when": 1783946203000, "tag": "0090_add_bananaimage_provider_type", "breakpoints": true + }, + { + "idx": 77, + "version": "7", + "when": 1783936800000, + "tag": "0091_add_canvas_capability_tasks", + "breakpoints": true } ] } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 61c46ad9f..296a6cc42 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -947,6 +947,31 @@ export const canvasImageTask = pgTable("CanvasImageTask", { export type CanvasImageTask = InferSelectModel; +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; + export const enhanceProTask = pgTable("EnhanceProTask", { id: uuid("id").primaryKey().notNull().defaultRandom(), userId: uuid("userId") diff --git a/package.json b/package.json index cd893a74f..d107e84ef 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@icons-pack/react-simple-icons": "^13.7.0", "@langchain/core": "^1.1.19", "@langchain/langgraph": "^1.1.3", + "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.200.0", "@plait-board/markdown-to-drawnix": "0.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49e331cfa..743916bfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,10 @@ importers: version: 1.1.19(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(ws@8.20.0(bufferutil@4.0.9)) '@langchain/langgraph': specifier: ^1.1.3 - version: 1.1.3(@langchain/core@1.1.19(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(ws@8.20.0(bufferutil@4.0.9)))(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(zod-to-json-schema@3.24.5(zod@3.25.76))(zod@3.25.76) + version: 1.1.3(@langchain/core@1.1.19(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(ws@8.20.0(bufferutil@4.0.9)))(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -1547,6 +1550,12 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@huggingface/jinja@0.2.2': resolution: {integrity: sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==} engines: {node: '>=18'} @@ -1804,6 +1813,16 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@monogrid/gainmap-js@3.4.0': resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} peerDependencies: @@ -3681,6 +3700,10 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -3713,6 +3736,17 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} @@ -3790,6 +3824,10 @@ packages: bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} @@ -3846,6 +3884,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -3978,9 +4020,33 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -4237,6 +4303,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4388,6 +4458,9 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + elkjs@0.9.3: resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} @@ -4404,6 +4477,10 @@ packages: embla-carousel@8.6.0: resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -4463,6 +4540,9 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -4473,6 +4553,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -4495,6 +4579,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + exceljs@4.4.0: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} @@ -4503,6 +4591,16 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -4516,6 +4614,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -4541,6 +4642,10 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + flatbuffers@1.12.0: resolution: {integrity: sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==} @@ -4552,6 +4657,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + framer-motion@11.18.2: resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} peerDependencies: @@ -4566,6 +4675,10 @@ packages: react-dom: optional: true + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -4711,12 +4824,20 @@ packages: hls.js@1.6.15: resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} + hono@4.12.30: + resolution: {integrity: sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==} + engines: {node: '>=16.9.0'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -4769,6 +4890,14 @@ packages: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-alphabetical@1.0.4: resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} @@ -4829,6 +4958,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -4873,6 +5005,12 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -5222,6 +5360,14 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + mermaid@10.9.3: resolution: {integrity: sha512-V80X1isSEvAewIL3xhmz/rVmc27CVljcsbWxkxlWJWY/1kQa4XOABqpDl2qQLGKzpKm6WbTfUEKImBlUfFYArw==} @@ -5390,10 +5536,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -5467,6 +5621,10 @@ packages: native-duplexpair@1.0.0: resolution: {integrity: sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + next-auth@5.0.0-beta.30: resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==} peerDependencies: @@ -5533,9 +5691,17 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5610,6 +5776,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -5628,6 +5798,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -5657,6 +5830,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -5818,10 +5995,18 @@ packages: resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -5838,6 +6023,14 @@ packages: '@types/react-dom': optional: true + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + re-resizable@6.11.2: resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==} peerDependencies: @@ -6035,6 +6228,10 @@ packages: roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -6077,12 +6274,23 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -6101,6 +6309,22 @@ packages: shimmer@1.2.1: resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -6186,6 +6410,10 @@ packages: stats.js@0.17.0: resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -6333,6 +6561,10 @@ packages: resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} engines: {node: '>=14.14'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tokenlens@1.3.0: resolution: {integrity: sha512-qrwHFO7CI8HEd+UvKjlL+veTzCVr3N4AYp3cquGL6z62Q/OtoEHbTv5uNqiiejP54y7eR+h8VVRRpZbm3t/99Q==} @@ -6389,6 +6621,10 @@ packages: tunnel-rat@0.1.2: resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.8.2: resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} @@ -6438,6 +6674,10 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unzipper@0.10.14: resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} @@ -6514,6 +6754,10 @@ packages: engines: {node: '>=8'} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -6685,6 +6929,11 @@ packages: peerDependencies: zod: ^3.24.1 + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -8001,6 +8250,10 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@hono/node-server@1.19.14(hono@4.12.30)': + dependencies: + hono: 4.12.30 + '@huggingface/jinja@0.2.2': {} '@iconify/types@2.0.0': {} @@ -8180,7 +8433,7 @@ snapshots: react: 19.0.0-rc-45804af1-20241021 react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021) - '@langchain/langgraph@1.1.3(@langchain/core@1.1.19(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(ws@8.20.0(bufferutil@4.0.9)))(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(zod-to-json-schema@3.24.5(zod@3.25.76))(zod@3.25.76)': + '@langchain/langgraph@1.1.3(@langchain/core@1.1.19(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(ws@8.20.0(bufferutil@4.0.9)))(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(zod-to-json-schema@3.25.2(zod@3.25.76))(zod@3.25.76)': dependencies: '@langchain/core': 1.1.19(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(ws@8.20.0(bufferutil@4.0.9)) '@langchain/langgraph-checkpoint': 1.0.0(@langchain/core@1.1.19(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(ws@8.20.0(bufferutil@4.0.9))) @@ -8189,7 +8442,7 @@ snapshots: uuid: 10.0.0 zod: 3.25.76 optionalDependencies: - zod-to-json-schema: 3.24.5(zod@3.25.76) + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - react - react-dom @@ -8224,6 +8477,30 @@ snapshots: dependencies: langium: 3.3.1 + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.30) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.30 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + '@monogrid/gainmap-js@3.4.0(three@0.182.0)': dependencies: promise-worker-transferable: 1.0.4 @@ -10237,6 +10514,11 @@ snapshots: dependencies: event-target-shim: 5.0.1 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -10276,6 +10558,17 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.76 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-styles@5.2.0: {} archiver-utils@2.1.0: @@ -10368,6 +10661,20 @@ snapshots: bluebird@3.4.7: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bowser@2.13.1: {} brace-expansion@1.1.12: @@ -10419,6 +10726,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase@6.3.0: {} camera-controls@3.1.2(three@0.182.0): @@ -10534,8 +10846,23 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -10795,6 +11122,8 @@ snapshots: denque@2.1.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-gpu@5.0.70: @@ -10858,6 +11187,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: {} + elkjs@0.9.3: {} embla-carousel-react@8.6.0(react@19.0.0-rc-45804af1-20241021): @@ -10872,6 +11203,8 @@ snapshots: embla-carousel@8.6.0: {} + encodeurl@2.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -11017,6 +11350,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escape-html@1.0.3: {} + escape-string-regexp@5.0.0: {} estree-util-is-identifier-name@3.0.0: {} @@ -11025,6 +11360,8 @@ snapshots: dependencies: '@types/estree': 1.0.6 + etag@1.8.1: {} + event-target-shim@5.0.1: {} eventemitter3@4.0.7: {} @@ -11037,6 +11374,10 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + exceljs@4.4.0: dependencies: archiver: 5.3.2 @@ -11051,6 +11392,44 @@ snapshots: expect-type@1.2.2: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.7: {} extend@3.0.2: {} @@ -11062,6 +11441,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-uri@3.1.3: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -11087,6 +11468,17 @@ snapshots: fflate@0.8.2: {} + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + flatbuffers@1.12.0: {} form-data@4.0.5: @@ -11099,6 +11491,8 @@ snapshots: format@0.2.2: {} + forwarded@0.2.0: {} + framer-motion@11.18.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021): dependencies: motion-dom: 11.18.1 @@ -11108,6 +11502,8 @@ snapshots: react: 19.0.0-rc-45804af1-20241021 react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021) + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs.realpath@1.0.0: {} @@ -11336,10 +11732,20 @@ snapshots: hls.js@1.6.15: {} + hono@4.12.30: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -11392,6 +11798,10 @@ snapshots: intersection-observer@0.12.2: {} + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-alphabetical@1.0.4: {} is-alphabetical@2.0.1: {} @@ -11436,6 +11846,8 @@ snapshots: is-promise@2.2.2: {} + is-promise@4.0.0: {} + is-property@1.0.2: {} is-wsl@3.1.1: @@ -11471,6 +11883,10 @@ snapshots: js-tokens@9.0.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} jsonc-parser@3.3.1: {} @@ -11896,6 +12312,10 @@ snapshots: mdurl@2.0.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + mermaid@10.9.3: dependencies: '@braintree/sanitize-url': 6.0.4 @@ -12288,10 +12708,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.12 @@ -12364,6 +12790,8 @@ snapshots: native-duplexpair@1.0.0: {} + negotiator@1.0.0: {} + next-auth@5.0.0-beta.30(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021): dependencies: '@auth/core': 0.41.0 @@ -12420,9 +12848,15 @@ snapshots: object-assign@4.1.1: {} + object-inspect@1.13.4: {} + obuf@1.1.2: optional: true + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -12517,6 +12951,8 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-data-parser@0.1.0: {} path-expression-matcher@1.5.0: {} @@ -12527,6 +12963,8 @@ snapshots: path-parse@1.0.7: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -12555,6 +12993,8 @@ snapshots: picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -12771,8 +13211,18 @@ snapshots: '@types/node': 22.13.10 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode.js@2.3.1: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@0.2.11: {} radix-ui@1.4.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021): @@ -12838,6 +13288,15 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + re-resizable@6.11.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021): dependencies: react: 19.0.0-rc-45804af1-20241021 @@ -13126,6 +13585,16 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-applescript@7.1.0: {} rw@1.3.3: {} @@ -13156,10 +13625,37 @@ snapshots: semver@7.7.3: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + server-only@0.0.1: {} setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -13210,6 +13706,34 @@ snapshots: shimmer@1.2.1: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} sisteransi@1.0.5: {} @@ -13308,6 +13832,8 @@ snapshots: stats.js@0.17.0: {} + statuses@2.0.2: {} + std-env@3.10.0: {} streamdown@1.4.0(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021): @@ -13459,6 +13985,8 @@ snapshots: tmp@0.2.6: {} + toidentifier@1.0.1: {} + tokenlens@1.3.0: dependencies: '@tokenlens/core': 1.3.0 @@ -13518,6 +14046,12 @@ snapshots: - immer - react + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.8.2: {} uc.micro@2.1.0: {} @@ -13608,6 +14142,8 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + unpipe@1.0.0: {} + unzipper@0.10.14: dependencies: big-integer: 1.6.52 @@ -13674,6 +14210,8 @@ snapshots: kleur: 4.1.5 sade: 1.8.1 + vary@1.1.2: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -13833,6 +14371,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} zod@4.1.12: {} diff --git a/store/canvas/index.ts b/store/canvas/index.ts index 389721cd9..d867b5b43 100644 --- a/store/canvas/index.ts +++ b/store/canvas/index.ts @@ -15,6 +15,19 @@ import { normalizeStyleProPersistedState, type StyleProPersistedState, } 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 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; action: ImageNodeActionType; - requestId: string; +} + +export interface ImageNodeActionOptions { + args?: unknown; + source?: CanvasCapabilityCommandSource; + confirmed?: boolean; } type CanvasViewportSource = 'user' | 'agent-transient'; @@ -120,6 +138,8 @@ interface CanvasStore { pickedObject: PickedObject | null; pendingPickedObjects: PickedObject[]; imageNodeActionRequest: ImageNodeActionRequest | null; + imageNodeActionQueue: ImageNodeActionRequest[]; + handledCanvasCapabilityRequestIds: string[]; // 🆕 新增节点(用于自动聚焦) lastAddedNodeId: string | null; @@ -193,7 +213,14 @@ interface CanvasStore { setPickedObject: (obj: PickedObject | null) => void; addPendingPickedObject: (obj: PickedObject) => void; clearPendingPickedObjects: () => void; - requestImageNodeAction: (nodeId: string, action: ImageNodeActionType) => void; + dispatchCanvasCapabilityCommand: ( + command: CanvasCapabilityClientCommand + ) => void; + requestImageNodeAction: ( + nodeId: string, + action: ImageNodeActionType, + options?: ImageNodeActionOptions + ) => void; clearImageNodeActionRequest: () => void; // 🆕 自动聚焦控制 @@ -236,11 +263,18 @@ export const useCanvasStore = create()( pickedObject: null, pendingPickedObjects: [], imageNodeActionRequest: null, + imageNodeActionQueue: [], + handledCanvasCapabilityRequestIds: [], lastAddedNodeId: null, agentOperationRefreshUntil: 0, // 项目操作 setProject: (id, title) => set((state) => { + if (state.projectId !== id) { + state.imageNodeActionRequest = null; + state.imageNodeActionQueue = []; + state.handledCanvasCapabilityRequestIds = []; + } state.projectId = id; if (title) state.projectTitle = title; }), @@ -632,6 +666,9 @@ export const useCanvasStore = create()( state.canvasUpdatedAt = null; state.suppressedMediaKeys = []; state.styleProState = { history: [] }; + state.imageNodeActionRequest = null; + state.imageNodeActionQueue = []; + state.handledCanvasCapabilityRequestIds = []; state.history = []; state.historyIndex = -1; }), @@ -725,16 +762,85 @@ export const useCanvasStore = create()( state.pendingPickedObjects = []; }), - requestImageNodeAction: (nodeId, action) => set((state) => { - state.imageNodeActionRequest = { + dispatchCanvasCapabilityCommand: (command) => set((state) => { + 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, action, - requestId: generateId(), }; + if (state.imageNodeActionRequest) { + state.imageNodeActionQueue.push(request); + } else { + state.imageNodeActionRequest = request; + } }), clearImageNodeActionRequest: () => set((state) => { - state.imageNodeActionRequest = null; + state.imageNodeActionRequest = state.imageNodeActionQueue.shift() || null; }), clearLastAddedNodeId: () => set((state) => { diff --git a/tests/routes/canvas-capability-runtime.test.ts b/tests/routes/canvas-capability-runtime.test.ts new file mode 100644 index 000000000..04b4de6d7 --- /dev/null +++ b/tests/routes/canvas-capability-runtime.test.ts @@ -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([]); + }); +}); diff --git a/types/canvas/agent-protocol.ts b/types/canvas/agent-protocol.ts index 5af17939f..8aa5d0814 100644 --- a/types/canvas/agent-protocol.ts +++ b/types/canvas/agent-protocol.ts @@ -126,7 +126,7 @@ export type CanvasOperationKind = export interface CanvasMediaItem { mediaItemId: string; - type: "image" | "video"; + type: "image" | "video" | "model3d" | "world3d"; url: string; assetId?: string; width?: number;