diff --git a/lib/canvas/capabilities/headless-executor.ts b/lib/canvas/capabilities/headless-executor.ts index ff85cde59..98f746608 100644 --- a/lib/canvas/capabilities/headless-executor.ts +++ b/lib/canvas/capabilities/headless-executor.ts @@ -10,6 +10,7 @@ import { } from "@/lib/ai/rmbg/service"; import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context"; +import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint"; import { generateCanvasImage } from "@/lib/canvas/generate-image"; import { requestLongTaskResult } from "@/lib/client/long-task"; import { @@ -55,7 +56,7 @@ async function postRuntimeEndpoint(params: { requestUrl: string; timeoutMs?: number; }) { - const response = await fetch(new URL(params.path, params.requestUrl), { + const response = await fetch(resolveCanvasRuntimeEndpoint(params.path, params.requestUrl), { method: "POST", headers: { "Content-Type": "application/json", @@ -80,7 +81,7 @@ async function postRuntimeFormData(params: { requestUrl: string; timeoutMs?: number; }) { - const response = await fetch(new URL(params.path, params.requestUrl), { + const response = await fetch(resolveCanvasRuntimeEndpoint(params.path, params.requestUrl), { method: "POST", headers: getCookieHeaders(params.requestHeaders), body: params.formData, @@ -233,7 +234,7 @@ async function executeForTarget( if (command.capabilityId === "image.enhance") { const result = await requestLongTaskResult({ - endpoint: new URL("/api/enhance", params.requestUrl).toString(), + endpoint: resolveCanvasRuntimeEndpoint("/api/enhance", params.requestUrl), payload: { imageUrl: target.sourceImageUrl, prompt: prompt || "图像增强处理", @@ -258,7 +259,10 @@ async function executeForTarget( if (command.capabilityId === "image.remove_watermark") { const result = await requestLongTaskResult({ - endpoint: new URL("/api/watermark-remove", params.requestUrl).toString(), + endpoint: resolveCanvasRuntimeEndpoint( + "/api/watermark-remove", + params.requestUrl + ), payload: { image_url: target.sourceImageUrl }, timeoutMs: 20 * 60 * 1000, headers: getCookieHeaders(params.requestHeaders), diff --git a/lib/canvas/capabilities/internal-endpoint.ts b/lib/canvas/capabilities/internal-endpoint.ts new file mode 100644 index 000000000..d83a39691 --- /dev/null +++ b/lib/canvas/capabilities/internal-endpoint.ts @@ -0,0 +1,32 @@ +type CanvasRuntimeEndpointOptions = { + internalOrigin?: string; + port?: string; +}; + +function normalizeInternalOrigin(value: string) { + const url = new URL(value); + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +export function resolveCanvasRuntimeEndpoint( + path: string, + requestUrl: string, + options: CanvasRuntimeEndpointOptions = {} +) { + const configuredOrigin = String( + options.internalOrigin ?? process.env.CANVAS_RUNTIME_INTERNAL_ORIGIN ?? "" + ).trim(); + if (configuredOrigin) { + return new URL(path, normalizeInternalOrigin(configuredOrigin)).toString(); + } + + const port = String(options.port ?? process.env.PORT ?? "").trim(); + if (/^\d{1,5}$/.test(port) && Number(port) > 0 && Number(port) <= 65_535) { + return new URL(path, `http://127.0.0.1:${port}`).toString(); + } + + return new URL(path, requestUrl).toString(); +} diff --git a/lib/canvas/capabilities/task-queue.ts b/lib/canvas/capabilities/task-queue.ts index aaafdc786..413d5ccc9 100644 --- a/lib/canvas/capabilities/task-queue.ts +++ b/lib/canvas/capabilities/task-queue.ts @@ -20,7 +20,11 @@ export async function enqueueCanvasCapabilityExecution(params: { const state = getQueueState(); const previous = state.tails.get(params.projectId) || Promise.resolve(); const execution = previous.catch(() => undefined).then(params.execute); - const tail = execution.finally(() => { + const settledExecution = execution.then( + () => undefined, + () => undefined + ); + const tail = settledExecution.finally(() => { if (state.tails.get(params.projectId) === tail) { state.tails.delete(params.projectId); } diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 7fbcc6b96..6862b52e1 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ /routes\/canvas-entry-performance\.test\.ts/, /routes\/settings-visual-consistency\.test\.ts/, /routes\/canvas-scale-adjust-intent\.test\.ts/, + /routes\/canvas-runtime-internal-endpoint\.test\.ts/, /routes\/model-classification\.test\.ts/, ], fullyParallel: true, diff --git a/tests/routes/canvas-runtime-internal-endpoint.test.ts b/tests/routes/canvas-runtime-internal-endpoint.test.ts new file mode 100644 index 000000000..f3404a0e6 --- /dev/null +++ b/tests/routes/canvas-runtime-internal-endpoint.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "@playwright/test"; + +import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint"; +import { + enqueueCanvasCapabilityExecution, + getCanvasCapabilityQueueDepth, +} from "@/lib/canvas/capabilities/task-queue"; + +test.describe("canvas runtime internal endpoints", () => { + test("uses the local HTTP listener for server-internal capability calls", () => { + expect( + resolveCanvasRuntimeEndpoint( + "/api/enhance", + "https://ai.yydd.ai/api/canvas/canvas-1/capabilities", + { port: "3125" } + ) + ).toBe("http://127.0.0.1:3125/api/enhance"); + expect( + resolveCanvasRuntimeEndpoint( + "/api/enhance", + "https://ai.yydd.ai/api/canvas/canvas-1/capabilities", + { internalOrigin: "http://canvas-runtime:3000/base" } + ) + ).toBe("http://canvas-runtime:3000/api/enhance"); + }); + + test("falls back to the incoming origin without an internal listener", () => { + expect( + resolveCanvasRuntimeEndpoint( + "/api/enhance", + "https://ai.yydd.ai/api/canvas/canvas-1/capabilities", + { port: "invalid" } + ) + ).toBe("https://ai.yydd.ai/api/enhance"); + }); + + test("continues the project queue after a failed capability execution", async () => { + await expect( + enqueueCanvasCapabilityExecution({ + projectId: "failed-capability-project", + execute: async () => { + throw new Error("expected capability failure"); + }, + }) + ).rejects.toThrow("expected capability failure"); + + await expect( + enqueueCanvasCapabilityExecution({ + projectId: "failed-capability-project", + execute: async () => "recovered", + }) + ).resolves.toBe("recovered"); + expect(getCanvasCapabilityQueueDepth("failed-capability-project")).toBe(0); + }); +});