Browse Source

Fix NewApiWG image model promotion

feature/canvas-chatbot-copilot
yuanzoo 2 months ago
parent
commit
2c2017bdf1
  1. 18
      src/app/api/generate/__tests__/route.test.ts
  2. 45
      src/app/api/generate/providers/__tests__/newapiwg.test.ts
  3. 65
      src/components/WorkflowCanvas.tsx
  4. 57
      src/store/__tests__/workflowStore.integration.test.ts
  5. 43
      src/store/execution/__tests__/generateVideoExecutor.test.ts
  6. 83
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  7. 18
      src/store/execution/generateVideoExecutor.ts
  8. 33
      src/store/execution/nanoBananaExecutor.ts
  9. 2
      src/store/execution/types.ts
  10. 28
      src/store/utils/defaultImageModel.ts
  11. 97
      src/store/workflowStore.ts
  12. 2
      src/types/providers.ts

18
src/app/api/generate/__tests__/route.test.ts

@ -41,7 +41,7 @@ vi.mock("@/lib/images", () => ({
deleteImages: vi.fn(),
}));
import { POST, clearFalInputMappingCache } from "../route";
import { POST, buildMediaResponse, clearFalInputMappingCache } from "../route";
// Store original env
const originalEnv = { ...process.env };
@ -909,6 +909,22 @@ describe("/api/generate route", () => {
});
describe("Response handling", () => {
it("should return image URL when provider output is URL-only", async () => {
const response = buildMediaResponse({
type: "image",
data: "",
url: "https://r2cdn.copilotbase.com/r2cdn2/generated.png",
});
const data = await response.json();
expect(data).toEqual({
success: true,
image: "https://r2cdn.copilotbase.com/r2cdn2/generated.png",
contentType: "image",
});
});
it("should return proper response structure with image", async () => {
process.env.GEMINI_API_KEY = "test-gemini-key";

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

@ -150,6 +150,22 @@ describe("NewApiWG generation payloads", () => {
if (urlStr.includes("/chat/completions") && init?.method === "POST") {
capturedBody = JSON.parse(init.body as string);
if (capturedBody?.model === "gpt-image-2-all") {
return new Response(JSON.stringify({
id: "chatcmpl-test",
object: "chat.completion",
model: "gpt-image-2-all",
choices: [{
message: {
role: "assistant",
content: "![image](https://r2cdn.copilotbase.com/r2cdn2/generated.png)\n\n",
},
finish_reason: "stop",
}],
}), {
status: 200,
});
}
return new Response(JSON.stringify({
choices: [{
message: {
@ -352,6 +368,35 @@ describe("NewApiWG generation payloads", () => {
expect(capturedBody!.messages).toEqual([{ role: "user", content: "draw a banana" }]);
});
it("extracts markdown image URLs from gpt-image-2-all chat responses", async () => {
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: {
id: "gpt-image-2-all",
name: "gpt-image-2-all",
description: null,
provider: "newapiwg",
capabilities: ["text-to-image", "image-to-image"],
},
prompt: "draw a banana",
images: ["data:image/png;base64,input"],
}));
expect(result.success).toBe(true);
expect(result.outputs?.[0]).toEqual({
type: "image",
data: "",
url: "https://r2cdn.copilotbase.com/r2cdn2/generated.png",
});
expect(capturedBody!.model).toBe("gpt-image-2-all");
expect(capturedBody!.messages).toEqual([{
role: "user",
content: [
{ type: "text", text: "draw a banana" },
{ type: "image_url", image_url: { url: "data:image/png;base64,input" } },
],
}]);
});
it("routes Seedream image models through images generations", async () => {
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: {

65
src/components/WorkflowCanvas.tsx

@ -375,7 +375,7 @@ export const isDraggingNodeRef = { current: false };
export function WorkflowCanvas() {
const { t } = useI18n();
const { nodes, edges, groups, isModalOpen, showQuickstart, navigationTarget, canvasNavigationSettings, dimmedNodeIds, skippedNodeIds, hoveredNodeId, providerSettings } =
const { nodes, edges, groups, isModalOpen, showQuickstart, navigationTarget, canvasNavigationSettings, dimmedNodeIds, skippedNodeIds, hoveredNodeId, providerSettings, providerEnvStatus } =
useWorkflowStore(useShallow((state) => ({
nodes: state.nodes,
edges: state.edges,
@ -388,6 +388,7 @@ export function WorkflowCanvas() {
skippedNodeIds: state.skippedNodeIds,
hoveredNodeId: state.hoveredNodeId,
providerSettings: state.providerSettings,
providerEnvStatus: state.providerEnvStatus,
})));
const onNodesChange = useWorkflowStore((state) => state.onNodesChange);
const onEdgesChange = useWorkflowStore((state) => state.onEdgesChange);
@ -409,6 +410,7 @@ export function WorkflowCanvas() {
const clearWorkflow = useWorkflowStore((state) => state.clearWorkflow);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const setHoveredNodeId = useWorkflowStore((state) => state.setHoveredNodeId);
const updateProviderEnvStatus = useWorkflowStore((state) => state.updateProviderEnvStatus);
const openAnnotationModal = useAnnotationStore((state) => state.openModal);
const { screenToFlowPosition, getViewport, zoomIn, zoomOut, setViewport, setCenter } = useReactFlow();
const { show: showToast } = useToast();
@ -420,6 +422,24 @@ export function WorkflowCanvas() {
const [isChatOpen, setIsChatOpen] = useState(false);
const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false);
const [showNewProjectSetup, setShowNewProjectSetup] = useState(false);
useEffect(() => {
let cancelled = false;
fetch("/api/env-status")
.then((response) => response.ok ? response.json() : null)
.then((status) => {
if (!cancelled && status) {
updateProviderEnvStatus(status);
}
})
.catch(() => {
// Settings modal can still show provider errors; this status is only a hint.
});
return () => {
cancelled = true;
};
}, [updateProviderEnvStatus]);
const [expandingNode, setExpandingNode] = useState<{ id: string; type: string } | null>(null);
// Fallback model picker state
@ -2834,11 +2854,14 @@ export function WorkflowCanvas() {
onModelSelected={(model) => {
const currentNode = nodes.find((node) => node.id === fallbackDialogState.nodeId);
const currentData = currentNode?.data as any;
const selectedModel =
fallbackDialogState.capability === "video" &&
const shouldPromoteFallback =
(fallbackDialogState.capability === "image" || fallbackDialogState.capability === "video") &&
currentData?.selectedModel?.provider === "gemini" &&
!providerSettings.providers.gemini?.apiKey &&
model.provider !== "gemini"
!providerEnvStatus?.gemini &&
model.provider !== "gemini";
const selectedModel =
shouldPromoteFallback
? {
provider: model.provider,
modelId: model.id,
@ -2846,16 +2869,30 @@ export function WorkflowCanvas() {
capabilities: model.capabilities,
}
: undefined;
updateNodeData(fallbackDialogState.nodeId, {
...(selectedModel ? { selectedModel, inputSchema: undefined } : {}),
fallbackModel: {
provider: model.provider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
},
fallbackParameters: {},
});
if (selectedModel) {
updateNodeData(fallbackDialogState.nodeId, {
selectedModel,
parameters: {},
inputSchema: undefined,
fallbackModel: undefined,
fallbackParameters: undefined,
status: currentData?.status === "error" ? "idle" : currentData?.status,
error: currentData?.status === "error" ? null : currentData?.error,
__usedFallback: undefined,
__fallbackModelUsed: undefined,
__primaryError: undefined,
});
} else {
updateNodeData(fallbackDialogState.nodeId, {
fallbackModel: {
provider: model.provider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
},
fallbackParameters: {},
});
}
setFallbackDialogState(null);
}}
/>

57
src/store/__tests__/workflowStore.integration.test.ts

@ -10,6 +10,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act } from "@testing-library/react";
import { useWorkflowStore } from "../workflowStore";
import { defaultProviderSettings } from "../utils/localStorage";
import type { WorkflowNode, WorkflowEdge } from "@/types";
// Mock the Toast hook
@ -2305,6 +2306,62 @@ describe("workflowStore integration tests", () => {
expect(useWorkflowStore.getState().viewedCommentNodeIds.size).toBe(0);
});
it("should promote a NewApiWG fallback when env status is known before workflow load", async () => {
useWorkflowStore.setState({
providerSettings: defaultProviderSettings,
providerEnvStatus: {
newapiwg: true,
gemini: false,
openai: false,
anthropic: false,
replicate: false,
fal: false,
kie: false,
wavespeed: false,
},
});
const store = useWorkflowStore.getState();
await store.loadWorkflow({
id: "test-workflow",
name: "Test",
nodes: [
createTestNode("nanoBanana-1", "nanoBanana", {
selectedModel: {
provider: "gemini",
modelId: "nano-banana-pro",
displayName: "Nano Banana Pro",
},
fallbackModel: {
provider: "newapiwg",
modelId: "gpt-image-2-all",
displayName: "gpt-image-2-all",
},
fallbackParameters: { size: "1024x1024", quality: "auto" },
status: "error",
error: "Primary failed: API key not configured",
__usedFallback: true,
__fallbackModelUsed: "gpt-image-2-all",
__primaryError: "API key not configured",
}),
],
edges: [],
});
const node = useWorkflowStore.getState().nodes[0];
expect(node.data.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "gpt-image-2-all",
});
expect(node.data.parameters).toEqual({ size: "1024x1024", quality: "auto" });
expect(node.data.fallbackModel).toBeUndefined();
expect(node.data.fallbackParameters).toBeUndefined();
expect(node.data.status).toBe("idle");
expect(node.data.error).toBeNull();
expect(node.data.__usedFallback).toBeUndefined();
expect(useWorkflowStore.getState().hasUnsavedChanges).toBe(true);
});
});
});

43
src/store/execution/__tests__/generateVideoExecutor.test.ts

@ -242,12 +242,15 @@ describe("executeGenerateVideo", () => {
modelId: "viduq2-pro-fast",
});
expect(body.parameters).toEqual({ duration: 5 });
expect(ctx.updateNodeData).toHaveBeenCalledWith("vid-1", {
expect(ctx.updateNodeData).toHaveBeenCalledWith("vid-1", expect.objectContaining({
selectedModel: expect.objectContaining({
provider: "newapiwg",
modelId: "viduq2-pro-fast",
}),
});
parameters: { duration: 5 },
fallbackModel: undefined,
fallbackParameters: undefined,
}));
});
it("uses NewApiWG default video model when Gemini is unavailable and no fallback is set", async () => {
@ -281,6 +284,42 @@ describe("executeGenerateVideo", () => {
});
});
it("uses NewApiWG default video model when NewApiWG is configured via env", async () => {
const node = makeNode({
selectedModel: {
provider: "gemini",
modelId: "veo-3.1-fast/image-to-video",
displayName: "VEO 3.1 Fast I2V",
},
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/video.mp4" }),
});
const ctx = makeCtx(node, {
providerEnvStatus: {
newapiwg: true,
gemini: false,
openai: false,
anthropic: false,
replicate: false,
fal: false,
kie: false,
wavespeed: false,
},
});
await executeGenerateVideo(ctx);
const request = mockFetch.mock.calls[0];
const body = JSON.parse(request[1].body);
expect(body.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "viduq2-pro-fast",
});
expect(request[1].headers["X-NewApiWG-Key"]).toBeUndefined();
});
it("should update node with video result on success", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({

83
src/store/execution/__tests__/nanoBananaExecutor.test.ts

@ -217,12 +217,93 @@ describe("executeNanoBanana", () => {
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.selectedModel.provider).toBe("newapiwg");
expect(body.selectedModel.modelId).toBe("apiyi_nano_banana_2");
expect(ctx.updateNodeData).toHaveBeenCalledWith("gen-1", {
expect(ctx.updateNodeData).toHaveBeenCalledWith("gen-1", expect.objectContaining({
selectedModel: expect.objectContaining({
provider: "newapiwg",
modelId: "apiyi_nano_banana_2",
}),
fallbackModel: undefined,
fallbackParameters: undefined,
}));
});
it("reroutes stored Gemini image nodes when NewApiWG is configured via env", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }),
});
const ctx = makeCtx(node, {
providerEnvStatus: {
newapiwg: true,
gemini: false,
openai: false,
anthropic: false,
replicate: false,
fal: false,
kie: false,
wavespeed: false,
},
});
await executeNanoBanana(ctx);
const request = mockFetch.mock.calls[0];
const body = JSON.parse(request[1].body);
expect(body.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "apiyi_nano_banana_2",
});
expect(request[1].headers["X-NewApiWG-Key"]).toBeUndefined();
});
it("promotes a NewApiWG fallback image model when stored Gemini has no key", async () => {
const node = makeNode({
fallbackModel: {
provider: "newapiwg",
modelId: "gpt-image-2-all",
displayName: "gpt-image-2-all",
capabilities: ["text-to-image", "image-to-image"],
},
fallbackParameters: { quality: "auto", size: "1024x1024" },
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,result" }),
});
const ctx = makeCtx(node, {
providerEnvStatus: {
newapiwg: true,
gemini: false,
openai: false,
anthropic: false,
replicate: false,
fal: false,
kie: false,
wavespeed: false,
},
});
await executeNanoBanana(ctx);
expect(mockFetch).toHaveBeenCalledTimes(1);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "gpt-image-2-all",
});
expect(body.parameters).toEqual({ quality: "auto", size: "1024x1024" });
expect(ctx.updateNodeData).toHaveBeenCalledWith("gen-1", expect.objectContaining({
selectedModel: expect.objectContaining({
provider: "newapiwg",
modelId: "gpt-image-2-all",
}),
parameters: { quality: "auto", size: "1024x1024" },
fallbackModel: undefined,
fallbackParameters: undefined,
}));
});
it("should update node with result on success", async () => {

18
src/store/execution/generateVideoExecutor.ts

@ -68,6 +68,7 @@ export async function executeGenerateVideo(
getFreshNode,
signal,
providerSettings,
providerEnvStatus,
addIncurredCost,
generationsPath,
getNodes,
@ -119,14 +120,14 @@ export async function executeGenerateVideo(
let primaryParameters: Record<string, unknown> | undefined;
let fallbackModel = nodeData.fallbackModel;
if (!primaryModel?.modelId && shouldPreferNewApiWGVideoModel(providerSettings)) {
if (!primaryModel?.modelId && shouldPreferNewApiWGVideoModel(providerSettings, providerEnvStatus)) {
primaryModel = createNewApiWGDefaultVideoModel();
updateNodeData(node.id, { selectedModel: primaryModel });
}
if (
primaryModel?.provider === "gemini" &&
shouldPreferNewApiWGVideoModel(providerSettings)
shouldPreferNewApiWGVideoModel(providerSettings, providerEnvStatus)
) {
const promotedModel =
fallbackModel && fallbackModel.provider !== "gemini"
@ -137,7 +138,18 @@ export async function executeGenerateVideo(
? nodeData.fallbackParameters
: nodeData.parameters;
fallbackModel = undefined;
updateNodeData(node.id, { selectedModel: promotedModel });
updateNodeData(node.id, {
selectedModel: promotedModel,
parameters: primaryParameters ?? {},
fallbackModel: undefined,
fallbackParameters: undefined,
status: nodeData.status === "error" ? "idle" : nodeData.status,
error: nodeData.status === "error" ? null : nodeData.error,
__usedFallback: undefined,
__fallbackModelUsed: undefined,
__primaryError: undefined,
inputSchema: undefined,
});
}
if (!primaryModel?.modelId) {

33
src/store/execution/nanoBananaExecutor.ts

@ -43,6 +43,7 @@ export async function executeNanoBanana(
getNodes,
signal,
providerSettings,
providerEnvStatus,
addIncurredCost,
addToGlobalHistory,
generationsPath,
@ -357,20 +358,42 @@ export async function executeNanoBanana(
// Synthesize a SelectedModel for the primary from legacy fields if selectedModel is missing.
let primaryModel: SelectedModel =
nodeData.selectedModel ?? createDefaultImageModel(nodeData.model, providerSettings);
nodeData.selectedModel ?? createDefaultImageModel(nodeData.model, providerSettings, providerEnvStatus);
let primaryParameters: Record<string, unknown> | undefined;
let fallbackModel = nodeData.fallbackModel;
if (
primaryModel.provider === "gemini" &&
shouldPreferNewApiWGImageModel(providerSettings)
shouldPreferNewApiWGImageModel(providerSettings, providerEnvStatus)
) {
primaryModel = createNewApiWGDefaultImageModel();
updateNodeData(node.id, { selectedModel: primaryModel });
const promotedModel =
fallbackModel && fallbackModel.provider !== "gemini"
? fallbackModel
: createNewApiWGDefaultImageModel();
primaryModel = promotedModel;
primaryParameters = fallbackModel === promotedModel
? nodeData.fallbackParameters
: nodeData.parameters;
fallbackModel = undefined;
updateNodeData(node.id, {
selectedModel: promotedModel,
parameters: primaryParameters ?? {},
fallbackModel: undefined,
fallbackParameters: undefined,
status: nodeData.status === "error" ? "idle" : nodeData.status,
error: nodeData.status === "error" ? null : nodeData.error,
__usedFallback: undefined,
__fallbackModelUsed: undefined,
__primaryError: undefined,
inputSchema: undefined,
});
}
await runWithFallback({
nodeId: node.id,
primary: primaryModel,
fallback: nodeData.fallbackModel,
primaryParameters,
fallback: fallbackModel,
fallbackParameters: nodeData.fallbackParameters,
updateNodeData,
runOnce,

2
src/store/execution/types.ts

@ -10,6 +10,7 @@ import type {
WorkflowEdge,
WorkflowNodeData,
ProviderSettings,
ProviderEnvStatus,
ImageHistoryItem,
} from "@/types";
import type { ConnectedInputs } from "@/store/utils/connectedInputs";
@ -41,6 +42,7 @@ export interface NodeExecutionContext {
getNodes: () => WorkflowNode[];
signal?: AbortSignal;
providerSettings: ProviderSettings;
providerEnvStatus?: ProviderEnvStatus | null;
addIncurredCost: (cost: number) => void;
addToGlobalHistory: (item: Omit<ImageHistoryItem, "id">) => void;
generationsPath: string | null;

28
src/store/utils/defaultImageModel.ts

@ -1,4 +1,4 @@
import type { ModelType, ProviderSettings, SelectedModel } from "@/types";
import type { ModelType, ProviderEnvStatus, ProviderSettings, ProviderType, SelectedModel } from "@/types";
import { MODEL_DISPLAY_NAMES } from "@/types";
const NEWAPIWG_DEFAULT_IMAGE_MODEL_ID = "apiyi_nano_banana_2";
@ -40,23 +40,37 @@ export function createGeminiLegacyImageModel(model: string): SelectedModel {
};
}
function hasProviderCredential(
providerSettings: ProviderSettings,
provider: ProviderType,
providerEnvStatus?: ProviderEnvStatus | null
): boolean {
return Boolean(
providerSettings.providers[provider]?.apiKey ||
providerEnvStatus?.[provider]
);
}
export function shouldPreferNewApiWGImageModel(
providerSettings: ProviderSettings
providerSettings: ProviderSettings,
providerEnvStatus?: ProviderEnvStatus | null
): boolean {
return Boolean(providerSettings.providers.newapiwg?.apiKey);
return hasProviderCredential(providerSettings, "newapiwg", providerEnvStatus);
}
export function shouldPreferNewApiWGVideoModel(
providerSettings: ProviderSettings
providerSettings: ProviderSettings,
providerEnvStatus?: ProviderEnvStatus | null
): boolean {
return Boolean(providerSettings.providers.newapiwg?.apiKey);
return hasProviderCredential(providerSettings, "newapiwg", providerEnvStatus);
}
export function createDefaultImageModel(
legacyModel: string,
providerSettings: ProviderSettings
providerSettings: ProviderSettings,
providerEnvStatus?: ProviderEnvStatus | null
): SelectedModel {
if (shouldPreferNewApiWGImageModel(providerSettings)) {
if (shouldPreferNewApiWGImageModel(providerSettings, providerEnvStatus)) {
return createNewApiWGDefaultImageModel();
}
return createGeminiLegacyImageModel(legacyModel);

97
src/store/workflowStore.ts

@ -14,6 +14,7 @@ import {
WorkflowEdge,
NodeType,
NanoBananaNodeData,
GenerateVideoNodeData,
OutputGalleryNodeData,
WorkflowNodeData,
ImageHistoryItem,
@ -21,6 +22,8 @@ import {
GroupColor,
ProviderType,
ProviderSettings,
ProviderEnvStatus,
SelectedModel,
RecentModel,
CanvasNavigationSettings,
MatchMode,
@ -336,9 +339,11 @@ interface WorkflowStore {
// Provider settings state
providerSettings: ProviderSettings;
providerEnvStatus: ProviderEnvStatus | null;
// Provider settings actions
updateProviderSettings: (settings: ProviderSettings) => void;
updateProviderEnvStatus: (status: ProviderEnvStatus) => void;
updateProviderApiKey: (providerId: ProviderType, apiKey: string | null) => void;
toggleProvider: (providerId: ProviderType, enabled: boolean) => void;
@ -528,6 +533,65 @@ function syncUndoFlags(set: (partial: Partial<WorkflowStore>) => void): void {
set({ canUndo: undoManager.canUndo, canRedo: undoManager.canRedo });
}
function hasProviderCredential(
providerSettings: ProviderSettings,
providerEnvStatus: ProviderEnvStatus | null,
provider: ProviderType
): boolean {
return Boolean(
providerSettings.providers[provider]?.apiKey ||
providerEnvStatus?.[provider]
);
}
function shouldPromoteFallbackModel(
data: { selectedModel?: SelectedModel; fallbackModel?: SelectedModel },
providerSettings: ProviderSettings,
providerEnvStatus: ProviderEnvStatus | null
): boolean {
const fallbackProvider = data.fallbackModel?.provider;
return Boolean(
data.selectedModel?.provider === "gemini" &&
fallbackProvider &&
fallbackProvider !== "gemini" &&
!hasProviderCredential(providerSettings, providerEnvStatus, "gemini") &&
hasProviderCredential(providerSettings, providerEnvStatus, fallbackProvider)
);
}
function promoteUnavailableGeminiFallbacks(
nodes: WorkflowNode[],
providerSettings: ProviderSettings,
providerEnvStatus: ProviderEnvStatus | null
): { nodes: WorkflowNode[]; changed: boolean } {
let changed = false;
const nextNodes = nodes.map((node) => {
if (node.type !== "nanoBanana" && node.type !== "generateVideo") return node;
const data = node.data as NanoBananaNodeData | GenerateVideoNodeData;
if (!shouldPromoteFallbackModel(data, providerSettings, providerEnvStatus)) return node;
changed = true;
return {
...node,
data: {
...data,
selectedModel: data.fallbackModel,
parameters: data.fallbackParameters ?? data.parameters ?? {},
fallbackModel: undefined,
fallbackParameters: undefined,
status: data.status === "error" ? "idle" : data.status,
error: data.status === "error" ? null : data.error,
__usedFallback: undefined,
__fallbackModelUsed: undefined,
__primaryError: undefined,
inputSchema: undefined,
} as WorkflowNodeData,
} as WorkflowNode;
}) as WorkflowNode[];
return { nodes: nextNodes, changed };
}
const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
nodes: [],
edges: [],
@ -562,6 +626,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
// Provider settings initial state
providerSettings: getProviderSettings(),
providerEnvStatus: null,
// Model search dialog initial state
modelSearchOpen: false,
@ -1216,6 +1281,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
getNodes: () => get().nodes,
signal,
providerSettings: get().providerSettings,
providerEnvStatus: get().providerEnvStatus,
addIncurredCost: (cost: number) => get().addIncurredCost(cost),
addToGlobalHistory: (item) => get().addToGlobalHistory(item),
generationsPath: get().generationsPath,
@ -2163,6 +2229,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
// Migrate legacy nanoBanana nodes: derive selectedModel from model field if missing
const providerSettings = get().providerSettings;
const providerEnvStatus = get().providerEnvStatus;
workflow.nodes = workflow.nodes.map((node) => {
if (node.type === "nanoBanana") {
const data = node.data as NanoBananaNodeData;
@ -2171,7 +2238,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
...node,
data: {
...data,
selectedModel: createDefaultImageModel(data.model, providerSettings),
selectedModel: createDefaultImageModel(data.model, providerSettings, providerEnvStatus),
},
};
}
@ -2232,6 +2299,18 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
}
}
const promotedModels = promoteUnavailableGeminiFallbacks(
hydratedWorkflow.nodes as WorkflowNode[],
providerSettings,
providerEnvStatus
);
if (promotedModels.changed) {
hydratedWorkflow = {
...hydratedWorkflow,
nodes: promotedModels.nodes,
};
}
// Load cost data for this workflow
const costData = workflow.id ? loadWorkflowCostData(workflow.id) : null;
@ -2260,7 +2339,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
? joinBrowserFileSystemPath(directoryPath, "generations")
: null),
lastSavedAt: savedConfig?.lastSavedAt || null,
hasUnsavedChanges: false,
hasUnsavedChanges: promotedModels.changed,
// Restore cost data
incurredCost: costData?.incurredCost || 0,
// Track where imageRefs are valid from
@ -2655,10 +2734,22 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
// Provider settings actions
updateProviderSettings: (settings: ProviderSettings) => {
set({ providerSettings: settings });
const promoted = promoteUnavailableGeminiFallbacks(get().nodes, settings, get().providerEnvStatus);
set({
providerSettings: settings,
...(promoted.changed ? { nodes: promoted.nodes, hasUnsavedChanges: true } : {}),
});
saveProviderSettings(settings);
},
updateProviderEnvStatus: (status: ProviderEnvStatus) => {
const promoted = promoteUnavailableGeminiFallbacks(get().nodes, get().providerSettings, status);
set({
providerEnvStatus: status,
...(promoted.changed ? { nodes: promoted.nodes, hasUnsavedChanges: true } : {}),
});
},
updateProviderApiKey: (providerId: ProviderType, apiKey: string | null) => {
const { providerSettings } = get();
const updated: ProviderSettings = {

2
src/types/providers.ts

@ -37,6 +37,8 @@ export interface ProviderSettings {
providers: Record<ProviderType, ProviderConfig>;
}
export type ProviderEnvStatus = Record<ProviderType, boolean>;
// LLM Provider Options
export type LLMProvider = "newapiwg" | "google" | "openai" | "anthropic";

Loading…
Cancel
Save