Browse Source

增加debug, 旧节点转新节点

feature/composer
TianYun 4 weeks ago
parent
commit
899e80a3b1
  1. 4
      .env.example
  2. 26
      src/app/api/_generationGatewayDebug.ts
  3. 8
      src/app/api/env-status/route.ts
  4. 21
      src/app/api/generate/providers/__tests__/newapiwg.test.ts
  5. 58
      src/app/api/generate/providers/newapiwg.ts
  6. 39
      src/app/api/generate/providers/popiserver.ts
  7. 3
      src/components/nodes/SmartAudioNode.tsx
  8. 3
      src/components/nodes/SmartImageNode.tsx
  9. 3
      src/components/nodes/SmartVideoNode.tsx
  10. 95
      src/store/utils/__tests__/workflowMigration.test.ts
  11. 179
      src/store/utils/workflowMigration.ts
  12. 8
      src/store/workflowStore.ts
  13. 4
      src/types/providers.ts
  14. 6
      src/utils/smartGenerateUpload.ts

4
.env.example

@ -5,6 +5,10 @@ NEXT_PUBLIC_PROVIDER_MODE=popi
# Popi Models gateway
NEWAPIWG_BASE_URL=https://llmapitest.popi.art/v1
NEWAPIWG_API_KEY=your_newapiwg_api_key_here
# Set true to log image/video generation gateway parameters without sending the generation request.
SKIP_GENERATION_GATEWAY=false
# Set false to hide upload controls on smart nodes while they are in generate mode.
SMART_GENERATE_UPLOAD_ENABLED=true
# Test server deployment
NEXT_PUBLIC_APP_URL=https://wwwtestcanvas.popi.art

26
src/app/api/_generationGatewayDebug.ts

@ -0,0 +1,26 @@
import { logGatewayRequest } from "@/app/api/_gatewayLogging";
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
export const SKIP_GENERATION_GATEWAY_ENV = "SKIP_GENERATION_GATEWAY";
export function shouldSkipGenerationGateway(): boolean {
return TRUE_VALUES.has((process.env[SKIP_GENERATION_GATEWAY_ENV] || "").trim().toLowerCase());
}
export function logSkippedGenerationGatewayRequest(
label: string,
url: string,
init: RequestInit,
context?: Record<string, unknown>
): void {
logGatewayRequest(label, url, init, {
...(context || {}),
skipped: true,
skipEnv: SKIP_GENERATION_GATEWAY_ENV,
});
}
export function skippedGenerationGatewayMessage(provider: string): string {
return `${provider} generation gateway request skipped because ${SKIP_GENERATION_GATEWAY_ENV}=true. Check api.gateway logs for passed parameters.`;
}

8
src/app/api/env-status/route.ts

@ -10,6 +10,13 @@ export interface EnvStatusResponse {
fal: boolean;
kie: boolean;
wavespeed: boolean;
smartGenerateUpload: boolean;
}
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
function isSmartGenerateUploadEnabled(): boolean {
return !FALSE_VALUES.has((process.env.SMART_GENERATE_UPLOAD_ENABLED || "").trim().toLowerCase());
}
export async function GET() {
@ -24,6 +31,7 @@ export async function GET() {
fal: !!process.env.FAL_API_KEY,
kie: !!process.env.KIE_API_KEY,
wavespeed: !!process.env.WAVESPEED_API_KEY,
smartGenerateUpload: isSmartGenerateUploadEnabled(),
};
return NextResponse.json(status);

21
src/app/api/generate/providers/__tests__/newapiwg.test.ts

@ -471,6 +471,7 @@ describe("NewApiWG generation payloads", () => {
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.useRealTimers();
});
@ -500,6 +501,26 @@ describe("NewApiWG generation payloads", () => {
expect(capturedBody!.duration).toBe(5);
});
it("logs image generation parameters without calling the gateway when debug skip is enabled", async () => {
vi.stubEnv("SKIP_GENERATION_GATEWAY", "true");
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: {
id: "seedream-5-0-260128",
name: "Seedream",
description: null,
provider: "newapiwg",
capabilities: ["text-to-image"],
},
prompt: "draw a banana",
images: ["data:image/png;base64,input"],
}));
expect(result.success).toBe(false);
expect(result.error).toContain("SKIP_GENERATION_GATEWAY=true");
expect(vi.mocked(global.fetch)).not.toHaveBeenCalled();
});
it("maps stale Seedance frame aliases to gateway images", async () => {
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
dynamicInputs: {

58
src/app/api/generate/providers/newapiwg.ts

@ -4,6 +4,11 @@ import { resolveTemporaryImageUrlToDataUrl } from "@/lib/images/temporaryUrls";
import { validateMediaUrl } from "@/utils/urlValidation";
import { buildGatewayHeaders } from "@/app/api/_auth";
import { logGatewayRequest, logGatewayResponse } from "@/app/api/_gatewayLogging";
import {
logSkippedGenerationGatewayRequest,
shouldSkipGenerationGateway,
skippedGenerationGatewayMessage,
} from "@/app/api/_generationGatewayDebug";
import type { SelectedModel } from "@/types";
import { filterReferenceSubjectList, modelSupportsReferenceMedia } from "@/utils/generationInputSupport";
@ -1315,7 +1320,8 @@ async function generateImageViaNewApiWGChat(
]
: prompt;
const { response, data } = await fetchNewApiWGJsonWithRetry(`${baseUrl}/chat/completions`, {
const url = `${baseUrl}/chat/completions`;
const init: RequestInit = {
method: "POST",
headers: buildGatewayHeaders(apiKey, loginToken, { "Content-Type": "application/json" }),
body: JSON.stringify({
@ -1325,7 +1331,20 @@ async function generateImageViaNewApiWGChat(
...dynamicInputsWithoutPrompt(input),
...normalizeOpenAIChatImageParameters(input.model.id, input.parameters),
}),
}, input.model.name, "NewApiWG chat image API error");
};
if (shouldSkipGenerationGateway()) {
logSkippedGenerationGatewayRequest("Skipped NewApiWG chat image API", url, init, {
modelName: input.model.name,
});
return { success: false, error: skippedGenerationGatewayMessage("NewApiWG") };
}
const { response, data } = await fetchNewApiWGJsonWithRetry(
url,
init,
input.model.name,
"NewApiWG chat image API error"
);
if (!response.ok) {
const message = getNewApiWGErrorMessage(data, `NewApiWG chat image API error: ${response.status}`);
@ -1499,7 +1518,8 @@ async function generateImageViaNewApiWGGemini(
: "2K";
const modelName = toGeminiNativeModelName(input.model.id);
const { response, data } = await fetchNewApiWGJsonWithRetry(`${normalizeNewApiWGGeminiBaseUrl(baseUrl)}/models/${encodeURIComponent(modelName)}:generateContent`, {
const url = `${normalizeNewApiWGGeminiBaseUrl(baseUrl)}/models/${encodeURIComponent(modelName)}:generateContent`;
const init: RequestInit = {
method: "POST",
headers: buildGatewayHeaders(apiKey, loginToken, { "Content-Type": "application/json" }),
body: JSON.stringify({
@ -1519,7 +1539,20 @@ async function generateImageViaNewApiWGGemini(
},
...dynamicInputsWithoutPrompt(input),
}),
}, input.model.name, "NewApiWG Gemini image API error");
};
if (shouldSkipGenerationGateway()) {
logSkippedGenerationGatewayRequest("Skipped NewApiWG Gemini image API", url, init, {
modelName: input.model.name,
});
return { success: false, error: skippedGenerationGatewayMessage("NewApiWG") };
}
const { response, data } = await fetchNewApiWGJsonWithRetry(
url,
init,
input.model.name,
"NewApiWG Gemini image API error"
);
if (!response.ok) {
const message = getNewApiWGErrorMessage(data, `NewApiWG Gemini image API error: ${response.status}`);
@ -1622,7 +1655,7 @@ export async function generateWithNewApiWG(
};
}
const { response, data } = await fetchNewApiWGJsonWithRetry(endpoint, {
const init: RequestInit = {
method: "POST",
headers: buildGatewayHeaders(apiKey, loginToken, { "Content-Type": "application/json" }),
body: JSON.stringify({
@ -1634,7 +1667,20 @@ export async function generateWithNewApiWG(
...(!isVideo ? { size: imageSize, response_format: "b64_json" } : {}),
...(isVideo ? sanitizeNewApiWGVideoParameters(input.model, input.parameters) : imageParameters),
}),
}, input.model.name, `NewApiWG ${isVideo ? "video" : "image"} API error`);
};
if (shouldSkipGenerationGateway()) {
logSkippedGenerationGatewayRequest(`Skipped NewApiWG ${isVideo ? "video" : "image"} API`, endpoint, init, {
modelName: input.model.name,
});
return { success: false, error: skippedGenerationGatewayMessage("NewApiWG") };
}
const { response, data } = await fetchNewApiWGJsonWithRetry(
endpoint,
init,
input.model.name,
`NewApiWG ${isVideo ? "video" : "image"} API error`
);
if (!response.ok) {
const message = getNewApiWGErrorMessage(data, `NewApiWG ${isVideo ? "video" : "image"} API error: ${response.status}`);

39
src/app/api/generate/providers/popiserver.ts

@ -8,6 +8,11 @@ import {
} from "@/app/api/_popiserverModels";
import { ApiError } from "@/app/api/_errors";
import { logGatewayRequest, logGatewayResponse } from "@/app/api/_gatewayLogging";
import {
logSkippedGenerationGatewayRequest,
shouldSkipGenerationGateway,
skippedGenerationGatewayMessage,
} from "@/app/api/_generationGatewayDebug";
import { modelSupportsReferenceMedia } from "@/utils/generationInputSupport";
const POPISERVER_TASK_CREATE_PATH = "/api_client/anime/task/create";
@ -579,11 +584,41 @@ export async function submitPopiTask(
}
const preparedInput = await preparePopiTaskMedia(request, token, input);
const body = buildPopiTaskBody(preparedInput, modelDetail);
const data = await fetchPopiJson<PopiTaskCreateData>(request, token, POPISERVER_TASK_CREATE_PATH, {
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
};
const isImageOrVideoTask = input.model.capabilities.some(
(capability) => capability.includes("image") || capability.includes("video")
);
if (isImageOrVideoTask && shouldSkipGenerationGateway()) {
const url = getPopiserverBaseUrl(request) + POPISERVER_TASK_CREATE_PATH;
logSkippedGenerationGatewayRequest(
"Skipped PopiServer task API",
url,
{
...init,
headers: {
...buildPopiserverForwardHeaders(request, token),
...(init.headers || {}),
},
cache: "no-store",
},
{
popiPath: POPISERVER_TASK_CREATE_PATH,
modelName: input.model.name,
}
);
throw ApiError.provider(skippedGenerationGatewayMessage("PopiServer"));
}
const data = await fetchPopiJson<PopiTaskCreateData>(
request,
token,
POPISERVER_TASK_CREATE_PATH,
init
);
const taskId = asString(data?.id) ?? asString(data?.taskId) ?? String(data?.id ?? data?.taskId ?? "");
if (!taskId) {
throw ApiError.provider("PopiServer task create response did not include a task id.");

3
src/components/nodes/SmartAudioNode.tsx

@ -186,8 +186,9 @@ function SmartAudioEmptyView({
export function SmartAudioNode({ id, data, selected }: NodeProps<SmartAudioNodeType>) {
const edges = useWorkflowStore((state) => state.edges);
const providerEnvStatus = useWorkflowStore((state) => state.providerEnvStatus);
const mode = deriveSmartAudioMode(id, data, edges);
const smartGenerateUploadEnabled = isSmartGenerateUploadEnabled();
const smartGenerateUploadEnabled = isSmartGenerateUploadEnabled(providerEnvStatus);
if (mode === "generate" || (smartGenerateUploadEnabled && isSmartAudioGenerateMode(id, edges))) {
if (smartGenerateUploadEnabled) {

3
src/components/nodes/SmartImageNode.tsx

@ -307,10 +307,11 @@ function SmartImageNeutralView({
export function SmartImageNode({ id, data, selected }: NodeProps<SmartImageNodeType>) {
const edges = useWorkflowStore((state) => state.edges);
const providerEnvStatus = useWorkflowStore((state) => state.providerEnvStatus);
const mode = deriveSmartImageMode(id, data, edges);
if (mode === "generate") {
if (isSmartGenerateUploadEnabled()) {
if (isSmartGenerateUploadEnabled(providerEnvStatus)) {
return <SmartImageNeutralView id={id} data={data} selected={selected} addToGenerationHistory />;
}
return <GenerateImageNodeView id={id} data={data} selected={selected} />;

3
src/components/nodes/SmartVideoNode.tsx

@ -333,10 +333,11 @@ function SmartVideoNeutralView({
export function SmartVideoNode({ id, data, selected }: NodeProps<SmartVideoNodeType>) {
const edges = useWorkflowStore((state) => state.edges);
const providerEnvStatus = useWorkflowStore((state) => state.providerEnvStatus);
const mode = deriveSmartVideoMode(id, data, edges);
if (mode === "generate") {
if (isSmartGenerateUploadEnabled()) {
if (isSmartGenerateUploadEnabled(providerEnvStatus)) {
return <SmartVideoNeutralView id={id} data={data} selected={selected} addToGenerationHistory />;
}
return <GenerateVideoNodeView id={id} data={data} selected={selected} />;

95
src/store/utils/__tests__/workflowMigration.test.ts

@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkflowEdge, WorkflowNode } from "@/types";
import { migrateLegacyWorkflowToSmartNodes } from "../workflowMigration";
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
clear: vi.fn(() => {
store = {};
}),
};
})();
Object.defineProperty(globalThis, "localStorage", {
value: localStorageMock,
writable: true,
});
function node(id: string, type: WorkflowNode["type"], data: Record<string, unknown>): WorkflowNode {
return {
id,
type,
position: { x: 0, y: 0 },
data,
} as WorkflowNode;
}
describe("migrateLegacyWorkflowToSmartNodes", () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});
it("leaves smart nodes unchanged", () => {
const smartImage = node("smartImage-1", "smartImage", { image: "image-url" });
const workflow = {
version: 1 as const,
name: "workflow",
nodes: [smartImage],
edges: [] as WorkflowEdge[],
edgeStyle: "angular" as const,
};
const result = migrateLegacyWorkflowToSmartNodes(workflow);
expect(result.changed).toBe(false);
expect(result.workflow).toBe(workflow);
expect(result.workflow.nodes[0]).toBe(smartImage);
});
it("converts legacy input and generation nodes to smart nodes", () => {
const workflow = {
version: 1 as const,
name: "workflow",
nodes: [
node("imageInput-1", "imageInput", { image: "uploaded-image", filename: "a.png", dimensions: null }),
node("nanoBanana-1", "nanoBanana", { outputImage: "generated-image", previewImg: "preview-image" }),
node("videoInput-1", "videoInput", { video: "uploaded-video", filename: "v.mp4" }),
node("generateVideo-1", "generateVideo", { outputVideo: "generated-video" }),
node("audioInput-1", "audioInput", { audioFile: "uploaded-audio", filename: "a.mp3" }),
node("generateAudio-1", "generateAudio", { outputAudio: "generated-audio" }),
node("prompt-1", "prompt", { prompt: "manual text" }),
node("llmGenerate-1", "llmGenerate", { inputPrompt: "write", outputText: "generated text" }),
],
edges: [] as WorkflowEdge[],
edgeStyle: "angular" as const,
};
const result = migrateLegacyWorkflowToSmartNodes(workflow);
expect(result.changed).toBe(true);
expect(result.workflow.nodes.map((item) => item.type)).toEqual([
"smartImage",
"smartImage",
"smartVideo",
"smartVideo",
"smartAudio",
"smartAudio",
"smartText",
"smartText",
]);
expect(result.workflow.nodes[0].data).toMatchObject({ image: "uploaded-image", filename: "a.png" });
expect(result.workflow.nodes[1].data).toMatchObject({ image: "generated-image", outputImage: "generated-image", previewImage: "preview-image" });
expect(result.workflow.nodes[2].data).toMatchObject({ video: "uploaded-video", filename: "v.mp4" });
expect(result.workflow.nodes[3].data).toMatchObject({ video: "generated-video", outputVideo: "generated-video" });
expect(result.workflow.nodes[4].data).toMatchObject({ audioFile: "uploaded-audio", filename: "a.mp3" });
expect(result.workflow.nodes[5].data).toMatchObject({ audioFile: "generated-audio", outputAudio: "generated-audio" });
expect(result.workflow.nodes[6].data).toMatchObject({ manualLocked: true, manualText: "manual text", outputText: null });
expect(result.workflow.nodes[7].data).toMatchObject({ manualLocked: false, inputPrompt: "write", outputText: "generated text" });
});
});

179
src/store/utils/workflowMigration.ts

@ -0,0 +1,179 @@
import type {
GenerateAudioNodeData,
GenerateVideoNodeData,
AudioInputNodeData,
ImageInputNodeData,
LLMGenerateNodeData,
NanoBananaNodeData,
PromptNodeData,
VideoInputNodeData,
WorkflowNode,
} from "@/types";
import { createDefaultNodeData } from "./nodeDefaults";
type WorkflowWithNodes<TNode extends WorkflowNode = WorkflowNode> = {
nodes: TNode[];
};
type LegacyNanoBananaData = NanoBananaNodeData & {
image?: string | null;
previewImage?: string;
};
type LegacyGenerateVideoData = GenerateVideoNodeData & {
video?: string | null;
};
type LegacyGenerateAudioData = GenerateAudioNodeData & {
audioFile?: string | null;
};
function migrateLegacyNodeToSmartNode(node: WorkflowNode): { node: WorkflowNode; changed: boolean } {
switch (node.type) {
case "imageInput":
return {
changed: true,
node: {
...node,
type: "smartImage",
data: {
...createDefaultNodeData("smartImage"),
...(node.data as ImageInputNodeData),
},
} as WorkflowNode,
};
case "nanoBanana": {
const data = node.data as LegacyNanoBananaData;
return {
changed: true,
node: {
...node,
type: "smartImage",
data: {
...createDefaultNodeData("smartImage"),
...data,
image: data.image ?? data.outputImage ?? null,
previewImage: data.previewImage ?? data.previewImg,
},
} as WorkflowNode,
};
}
case "videoInput":
return {
changed: true,
node: {
...node,
type: "smartVideo",
data: {
...createDefaultNodeData("smartVideo"),
...(node.data as VideoInputNodeData),
},
} as WorkflowNode,
};
case "generateVideo": {
const data = node.data as LegacyGenerateVideoData;
return {
changed: true,
node: {
...node,
type: "smartVideo",
data: {
...createDefaultNodeData("smartVideo"),
...data,
video: data.video ?? data.outputVideo ?? null,
},
} as WorkflowNode,
};
}
case "audioInput":
return {
changed: true,
node: {
...node,
type: "smartAudio",
data: {
...createDefaultNodeData("smartAudio"),
...(node.data as AudioInputNodeData),
},
} as WorkflowNode,
};
case "generateAudio": {
const data = node.data as LegacyGenerateAudioData;
return {
changed: true,
node: {
...node,
type: "smartAudio",
data: {
...createDefaultNodeData("smartAudio"),
...data,
audioFile: data.audioFile ?? data.outputAudio ?? null,
},
} as WorkflowNode,
};
}
case "prompt": {
const data = node.data as PromptNodeData;
return {
changed: true,
node: {
...node,
type: "smartText",
data: {
...createDefaultNodeData("smartText"),
...data,
manualLocked: true,
manualText: data.prompt ?? "",
outputText: null,
},
} as WorkflowNode,
};
}
case "llmGenerate":
return {
changed: true,
node: {
...node,
type: "smartText",
data: {
...createDefaultNodeData("smartText"),
...(node.data as LLMGenerateNodeData),
manualLocked: false,
},
} as WorkflowNode,
};
default:
return { node, changed: false };
}
}
export function migrateLegacyWorkflowToSmartNodes<TWorkflow extends WorkflowWithNodes>(
workflow: TWorkflow
): { workflow: TWorkflow; changed: boolean } {
let changed = false;
const nodes = workflow.nodes.map((node) => {
const result = migrateLegacyNodeToSmartNode(node);
if (result.changed) changed = true;
return result.node;
});
if (!changed) {
return { workflow, changed: false };
}
return {
changed: true,
workflow: {
...workflow,
nodes,
},
};
}

8
src/store/workflowStore.ts

@ -83,6 +83,7 @@ import {
import { getConnectedInputsPure, validateWorkflowPure, type ConnectedInputs } from "./utils/connectedInputs";
import { evaluateRule } from "./utils/ruleEvaluation";
import { computeDimmedNodes } from "./utils/dimmingUtils";
import { migrateLegacyWorkflowToSmartNodes } from "./utils/workflowMigration";
import {
executeAnnotation,
executeArray,
@ -3117,6 +3118,11 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
return node;
}) as WorkflowNode[];
const smartNodeMigration = migrateLegacyWorkflowToSmartNodes(workflow);
if (smartNodeMigration.changed) {
workflow = smartNodeMigration.workflow;
}
workflow.edges = workflow.edges.map((edge) => normalizeWorkflowEdgeHandles(edge));
// Deduplicate edges by ID (keep the last occurrence, which is the most recent)
@ -3206,7 +3212,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
incurredCost: costData?.incurredCost || 0,
// Reset viewed comments when loading new workflow
viewedCommentNodeIds: new Set<string>(),
hasUnsavedChanges: promotedModels.changed || restoredExecutionState.changed || hydratedModelDetails.changed || normalizedGroups.changed,
hasUnsavedChanges: smartNodeMigration.changed || promotedModels.changed || restoredExecutionState.changed || hydratedModelDetails.changed || normalizedGroups.changed,
});
// Clear snapshot unless explicitly preserving (e.g., AI workflow generation)

4
src/types/providers.ts

@ -42,7 +42,9 @@ export interface ProviderSettings {
providers: Record<ProviderType, ProviderConfig>;
}
export type ProviderEnvStatus = Record<ProviderType, boolean>;
export type ProviderEnvStatus = Record<ProviderType, boolean> & {
smartGenerateUpload?: boolean;
};
// LLM Provider Options
export type LLMProvider = "popiserver" | "newapiwg" | "google" | "openai" | "anthropic";

6
src/utils/smartGenerateUpload.ts

@ -1,3 +1,5 @@
export function isSmartGenerateUploadEnabled(): boolean {
return true;
import type { ProviderEnvStatus } from "@/types";
export function isSmartGenerateUploadEnabled(providerEnvStatus: ProviderEnvStatus | null): boolean {
return providerEnvStatus?.smartGenerateUpload !== false;
}

Loading…
Cancel
Save