From 899e80a3b10657fbc4fb885f30edcb04fb07303b Mon Sep 17 00:00:00 2001 From: TianYun Date: Mon, 15 Jun 2026 11:22:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0debug,=20=20=E6=97=A7?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=BD=AC=E6=96=B0=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + src/app/api/_generationGatewayDebug.ts | 26 +++ src/app/api/env-status/route.ts | 8 + .../providers/__tests__/newapiwg.test.ts | 21 ++ src/app/api/generate/providers/newapiwg.ts | 58 +++++- src/app/api/generate/providers/popiserver.ts | 39 +++- src/components/nodes/SmartAudioNode.tsx | 3 +- src/components/nodes/SmartImageNode.tsx | 3 +- src/components/nodes/SmartVideoNode.tsx | 3 +- .../utils/__tests__/workflowMigration.test.ts | 95 ++++++++++ src/store/utils/workflowMigration.ts | 179 ++++++++++++++++++ src/store/workflowStore.ts | 8 +- src/types/providers.ts | 4 +- src/utils/smartGenerateUpload.ts | 6 +- 14 files changed, 442 insertions(+), 15 deletions(-) create mode 100644 src/app/api/_generationGatewayDebug.ts create mode 100644 src/store/utils/__tests__/workflowMigration.test.ts create mode 100644 src/store/utils/workflowMigration.ts diff --git a/.env.example b/.env.example index d1e4917d..9f49824e 100644 --- a/.env.example +++ b/.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 diff --git a/src/app/api/_generationGatewayDebug.ts b/src/app/api/_generationGatewayDebug.ts new file mode 100644 index 00000000..144e1c81 --- /dev/null +++ b/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 +): 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.`; +} diff --git a/src/app/api/env-status/route.ts b/src/app/api/env-status/route.ts index 63550850..ace671e3 100644 --- a/src/app/api/env-status/route.ts +++ b/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); diff --git a/src/app/api/generate/providers/__tests__/newapiwg.test.ts b/src/app/api/generate/providers/__tests__/newapiwg.test.ts index 27a0e991..9fe6522e 100644 --- a/src/app/api/generate/providers/__tests__/newapiwg.test.ts +++ b/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: { diff --git a/src/app/api/generate/providers/newapiwg.ts b/src/app/api/generate/providers/newapiwg.ts index c6433359..44be3557 100644 --- a/src/app/api/generate/providers/newapiwg.ts +++ b/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}`); diff --git a/src/app/api/generate/providers/popiserver.ts b/src/app/api/generate/providers/popiserver.ts index 94a1b17b..e9619ac7 100644 --- a/src/app/api/generate/providers/popiserver.ts +++ b/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(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( + 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."); diff --git a/src/components/nodes/SmartAudioNode.tsx b/src/components/nodes/SmartAudioNode.tsx index 8f738cf0..ef569e1b 100644 --- a/src/components/nodes/SmartAudioNode.tsx +++ b/src/components/nodes/SmartAudioNode.tsx @@ -186,8 +186,9 @@ function SmartAudioEmptyView({ export function SmartAudioNode({ id, data, selected }: NodeProps) { 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) { diff --git a/src/components/nodes/SmartImageNode.tsx b/src/components/nodes/SmartImageNode.tsx index a158d92d..ea23e254 100644 --- a/src/components/nodes/SmartImageNode.tsx +++ b/src/components/nodes/SmartImageNode.tsx @@ -307,10 +307,11 @@ function SmartImageNeutralView({ export function SmartImageNode({ id, data, selected }: NodeProps) { 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 ; } return ; diff --git a/src/components/nodes/SmartVideoNode.tsx b/src/components/nodes/SmartVideoNode.tsx index 60fb687c..7d5748f7 100644 --- a/src/components/nodes/SmartVideoNode.tsx +++ b/src/components/nodes/SmartVideoNode.tsx @@ -333,10 +333,11 @@ function SmartVideoNeutralView({ export function SmartVideoNode({ id, data, selected }: NodeProps) { 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 ; } return ; diff --git a/src/store/utils/__tests__/workflowMigration.test.ts b/src/store/utils/__tests__/workflowMigration.test.ts new file mode 100644 index 00000000..e90013e6 --- /dev/null +++ b/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 = {}; + 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): 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" }); + }); +}); diff --git a/src/store/utils/workflowMigration.ts b/src/store/utils/workflowMigration.ts new file mode 100644 index 00000000..bec4fc49 --- /dev/null +++ b/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 = { + 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( + 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, + }, + }; +} diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index d0b2b659..2914e06e 100644 --- a/src/store/workflowStore.ts +++ b/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 = (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 = (set, get) => ({ incurredCost: costData?.incurredCost || 0, // Reset viewed comments when loading new workflow viewedCommentNodeIds: new Set(), - 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) diff --git a/src/types/providers.ts b/src/types/providers.ts index f07c171a..446d2cc3 100644 --- a/src/types/providers.ts +++ b/src/types/providers.ts @@ -42,7 +42,9 @@ export interface ProviderSettings { providers: Record; } -export type ProviderEnvStatus = Record; +export type ProviderEnvStatus = Record & { + smartGenerateUpload?: boolean; +}; // LLM Provider Options export type LLMProvider = "popiserver" | "newapiwg" | "google" | "openai" | "anthropic"; diff --git a/src/utils/smartGenerateUpload.ts b/src/utils/smartGenerateUpload.ts index 512e77d3..eec6042e 100644 --- a/src/utils/smartGenerateUpload.ts +++ b/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; }