Browse Source

Keep new Popi users on tested gateway defaults

New users now land on Popi gateway defaults that match the tested production-style path: APIYI Nano Banana 2 for images, Doubao Seedance for video, and Doubao Seed 2.0 Lite for LLM chat/tool use. Settings and FTUX screens render these effective defaults instead of empty placeholders, while existing local node defaults keep taking precedence.

The same pass keeps provider-overload errors user-facing by formatting the model name that the node currently displays, so fallback and switched-model failures are easier to understand across media nodes.

Constraint: Popi mode should hide internal NewApiWG naming and default to models available through the Popi gateway.

Rejected: Persist default values into localStorage for every new user | would make future system default changes harder and could overwrite explicit user preferences.

Confidence: high

Scope-risk: moderate

Directive: Do not change these defaults without checking the Popi gateway model ids and www.popi.art login/model-routing assumptions.

Tested: npm run test:run -- src/lib/__tests__/llmModels.test.ts src/store/utils/__tests__/nodeDefaults.test.ts src/store/execution/__tests__/generateVideoExecutor.test.ts src/components/__tests__/ProjectSetupModal.test.tsx src/components/__tests__/GenerationComposer.test.tsx src/app/api/chat/__tests__/route.test.ts src/components/__tests__/LLMGenerateNode.test.tsx src/utils/__tests__/userFacingErrors.test.ts

Tested: PROVIDER_MODE=popi NEXT_PUBLIC_PROVIDER_MODE=popi npm run build

Tested: git diff --check

Not-tested: npm run lint currently fails because next lint is no longer a valid project command in this Next.js setup
feature/add_agent_md
jiajia 2 months ago
parent
commit
87b56a4d3f
  1. 4
      src/app/api/chat/__tests__/route.test.ts
  2. 206
      src/components/ProjectSetupModal.tsx
  3. 42
      src/components/__tests__/GenerationComposer.test.tsx
  4. 16
      src/components/__tests__/LLMGenerateNode.test.tsx
  5. 32
      src/components/__tests__/ProjectSetupModal.test.tsx
  6. 5
      src/components/composer/GenerationComposer.tsx
  7. 16
      src/components/nodes/Generate3DNode.tsx
  8. 4
      src/components/nodes/GenerateAudioNode.tsx
  9. 20
      src/components/nodes/GenerateImageNode.tsx
  10. 20
      src/components/nodes/GenerateVideoNode.tsx
  11. 19
      src/components/nodes/LLMGenerateNode.tsx
  12. 142
      src/components/onboarding/FTUXModelDefaultsStep.tsx
  13. 12
      src/i18n/index.tsx
  14. 12
      src/lib/__tests__/llmModels.test.ts
  15. 2
      src/lib/chat/tools.ts
  16. 4
      src/lib/llmModels.ts
  17. 12
      src/store/execution/__tests__/generateVideoExecutor.test.ts
  18. 8
      src/store/utils/__tests__/nodeDefaults.test.ts
  19. 6
      src/store/utils/defaultImageModel.ts
  20. 31
      src/utils/__tests__/userFacingErrors.test.ts
  21. 23
      src/utils/userFacingErrors.ts

4
src/app/api/chat/__tests__/route.test.ts

@ -58,7 +58,7 @@ describe("/api/chat route", () => {
});
});
it("uses NewApiWG GPT-4.1 Mini for chat requests", async () => {
it("uses NewApiWG Doubao Seed 2.0 Lite for chat requests", async () => {
const request = new Request("http://localhost/api/chat", {
method: "POST",
headers: {
@ -80,7 +80,7 @@ describe("/api/chat route", () => {
apiKey: "browser-newapi-key",
baseURL: "https://llmapitest.popi.art/v1",
});
expect(mockNewApiWGProvider).toHaveBeenCalledWith("gpt-4.1-mini");
expect(mockNewApiWGProvider).toHaveBeenCalledWith("doubao-seed-2-0-lite-260428");
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
model: "newapiwg-model",

206
src/components/ProjectSetupModal.tsx

@ -10,8 +10,12 @@ import { ProviderModel } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { useI18n } from "@/i18n";
import { LLM_MODELS, LLM_PROVIDERS } from "@/lib/llmModels";
import { DEFAULT_NEWAPIWG_LLM_MODEL_ID, LLM_MODELS, LLM_PROVIDERS } from "@/lib/llmModels";
import { getProviderDisplayName, isPopiProviderMode } from "@/lib/providerMode";
import {
createNewApiWGDefaultImageModel,
createNewApiWGDefaultVideoModel,
} from "@/store/utils/defaultImageModel";
import {
isBrowserDirectoryPickerSupported,
isBrowserFileSystemPath,
@ -74,6 +78,13 @@ const getProviderIcon = (provider: ProviderType) => {
};
const DEFAULT_LLM_PROVIDER: LLMProvider = "newapiwg";
const DEFAULT_LLM_TEMPERATURE = 0.7;
const DEFAULT_LLM_MAX_TOKENS = 8192;
function getDefaultModelForLLMProvider(provider: LLMProvider): LLMModelType {
if (provider === DEFAULT_LLM_PROVIDER) return DEFAULT_NEWAPIWG_LLM_MODEL_ID;
return LLM_MODELS[provider][0].value;
}
interface ProjectSetupModalProps {
isOpen: boolean;
@ -414,6 +425,14 @@ export function ProjectSetupModal({
}));
};
const effectiveImageModel = localNodeDefaults.generateImage?.selectedModel ?? createNewApiWGDefaultImageModel();
const effectiveVideoModel = localNodeDefaults.generateVideo?.selectedModel ?? createNewApiWGDefaultVideoModel();
const effectiveLLMProvider = localNodeDefaults.llm?.provider ?? DEFAULT_LLM_PROVIDER;
const effectiveLLMModels = LLM_MODELS[effectiveLLMProvider] || LLM_MODELS.newapiwg;
const effectiveLLMModel = localNodeDefaults.llm?.model ?? getDefaultModelForLLMProvider(effectiveLLMProvider);
const effectiveLLMTemperature = localNodeDefaults.llm?.temperature ?? DEFAULT_LLM_TEMPERATURE;
const effectiveLLMMaxTokens = localNodeDefaults.llm?.maxTokens ?? DEFAULT_LLM_MAX_TOKENS;
if (!isOpen) return null;
return (
@ -965,44 +984,29 @@ export function ProjectSetupModal({
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-100">{t("ftux.defaultImageModel")}</span>
<div className="flex items-center gap-2">
{localNodeDefaults.generateImage?.selectedModel ? (
<>
<div className="flex items-center gap-1.5 text-xs text-neutral-300">
{getProviderIcon(localNodeDefaults.generateImage.selectedModel.provider)}
<span className="truncate max-w-[150px]">
{localNodeDefaults.generateImage.selectedModel.displayName}
</span>
</div>
<button
type="button"
onClick={() => setShowImageModelDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.change")}
</button>
<button
type="button"
onClick={() => {
const { generateImage, ...rest } = localNodeDefaults;
setLocalNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
</>
) : (
<>
<span className="text-xs text-neutral-400">{t("project.systemImageDefault")}</span>
<button
type="button"
onClick={() => setShowImageModelDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.selectModel")}
</button>
</>
)}
<div className="flex items-center gap-1.5 text-xs text-neutral-300">
{getProviderIcon(effectiveImageModel.provider)}
<span className="truncate max-w-[150px]">
{effectiveImageModel.displayName}
</span>
</div>
<button
type="button"
onClick={() => setShowImageModelDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.change")}
</button>
<button
type="button"
onClick={() => {
const { generateImage, ...rest } = localNodeDefaults;
setLocalNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
</div>
</div>
</div>
@ -1012,44 +1016,29 @@ export function ProjectSetupModal({
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-100">{t("ftux.defaultVideoModel")}</span>
<div className="flex items-center gap-2">
{localNodeDefaults.generateVideo?.selectedModel ? (
<>
<div className="flex items-center gap-1.5 text-xs text-neutral-300">
{getProviderIcon(localNodeDefaults.generateVideo.selectedModel.provider)}
<span className="truncate max-w-[150px]">
{localNodeDefaults.generateVideo.selectedModel.displayName}
</span>
</div>
<button
type="button"
onClick={() => setShowVideoModelDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.change")}
</button>
<button
type="button"
onClick={() => {
const { generateVideo, ...rest } = localNodeDefaults;
setLocalNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
</>
) : (
<>
<span className="text-xs text-neutral-400">{t("project.noneSetFirstUse")}</span>
<button
type="button"
onClick={() => setShowVideoModelDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.selectModel")}
</button>
</>
)}
<div className="flex items-center gap-1.5 text-xs text-neutral-300">
{getProviderIcon(effectiveVideoModel.provider)}
<span className="truncate max-w-[150px]">
{effectiveVideoModel.displayName}
</span>
</div>
<button
type="button"
onClick={() => setShowVideoModelDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.change")}
</button>
<button
type="button"
onClick={() => {
const { generateVideo, ...rest } = localNodeDefaults;
setLocalNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
</div>
</div>
</div>
@ -1059,39 +1048,32 @@ export function ProjectSetupModal({
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-100">{t("project.defaultLLM")}</span>
{localNodeDefaults.llm && (
<button
type="button"
onClick={() => {
const { llm, ...rest } = localNodeDefaults;
setLocalNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
)}
<button
type="button"
onClick={() => {
const { llm, ...rest } = localNodeDefaults;
setLocalNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
</div>
{!localNodeDefaults.llm ? (
<p className="text-xs text-neutral-400">{t("project.systemLLMDefault")}</p>
) : null}
{/* Provider dropdown */}
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-400 w-20">{t("project.provider")}</label>
<select
value={localNodeDefaults.llm?.provider || DEFAULT_LLM_PROVIDER}
value={effectiveLLMProvider}
onChange={(e) => {
const newProvider = e.target.value as LLMProvider;
const firstModelForProvider = LLM_MODELS[newProvider][0].value;
const currentTemp = localNodeDefaults.llm?.temperature ?? 0.7;
const currentTemp = localNodeDefaults.llm?.temperature ?? DEFAULT_LLM_TEMPERATURE;
setLocalNodeDefaults(prev => ({
...prev,
llm: {
...prev.llm,
provider: newProvider,
model: firstModelForProvider,
model: getDefaultModelForLLMProvider(newProvider),
// Clamp temperature for Anthropic (max 1.0)
...(newProvider === "anthropic" && currentTemp > 1 ? { temperature: 1 } : {}),
}
@ -1109,16 +1091,16 @@ export function ProjectSetupModal({
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-400 w-20">{t("project.model")}</label>
<select
value={localNodeDefaults.llm?.model || LLM_MODELS[localNodeDefaults.llm?.provider || DEFAULT_LLM_PROVIDER][0].value}
value={effectiveLLMModel}
onChange={(e) => {
setLocalNodeDefaults(prev => ({
...prev,
llm: { ...prev.llm, model: e.target.value as LLMModelType }
llm: { ...prev.llm, provider: effectiveLLMProvider, model: e.target.value as LLMModelType }
}));
}}
className="flex-1 px-2 py-1 text-xs bg-neutral-800 border border-neutral-600 rounded text-neutral-100 focus:outline-none focus:border-neutral-500"
>
{LLM_MODELS[localNodeDefaults.llm?.provider || DEFAULT_LLM_PROVIDER].map((m) => (
{effectiveLLMModels.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
@ -1127,18 +1109,23 @@ export function ProjectSetupModal({
{/* Temperature slider */}
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-400 w-20">
{t("project.temp", { value: (localNodeDefaults.llm?.temperature ?? 0.7).toFixed(1) })}
{t("project.temp", { value: effectiveLLMTemperature.toFixed(1) })}
</label>
<input
type="range"
min="0"
max={(localNodeDefaults.llm?.provider || DEFAULT_LLM_PROVIDER) === "anthropic" ? "1" : "2"}
max={effectiveLLMProvider === "anthropic" ? "1" : "2"}
step="0.1"
value={localNodeDefaults.llm?.temperature ?? 0.7}
value={effectiveLLMTemperature}
onChange={(e) => {
setLocalNodeDefaults(prev => ({
...prev,
llm: { ...prev.llm, temperature: parseFloat(e.target.value) }
llm: {
...prev.llm,
provider: effectiveLLMProvider,
model: effectiveLLMModel,
temperature: parseFloat(e.target.value),
}
}));
}}
className="flex-1 h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"
@ -1148,18 +1135,23 @@ export function ProjectSetupModal({
{/* Max Tokens slider */}
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-400 w-20">
{t("project.tokens", { value: (localNodeDefaults.llm?.maxTokens ?? 8192).toLocaleString() })}
{t("project.tokens", { value: effectiveLLMMaxTokens.toLocaleString() })}
</label>
<input
type="range"
min="256"
max="16384"
step="256"
value={localNodeDefaults.llm?.maxTokens ?? 8192}
value={effectiveLLMMaxTokens}
onChange={(e) => {
setLocalNodeDefaults(prev => ({
...prev,
llm: { ...prev.llm, maxTokens: parseInt(e.target.value, 10) }
llm: {
...prev.llm,
provider: effectiveLLMProvider,
model: effectiveLLMModel,
maxTokens: parseInt(e.target.value, 10),
}
}));
}}
className="flex-1 h-1 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-neutral-400"

42
src/components/__tests__/GenerationComposer.test.tsx

@ -31,11 +31,24 @@ vi.mock("@/components/modals/ModelSearchDialog", () => ({
onModelSelected: (model: {
id: string;
name: string;
provider: "fal" | "kie";
capabilities: Array<"text-to-video" | "image-to-video" | "text-to-audio">;
provider: "newapiwg" | "fal" | "kie";
capabilities: Array<"text-to-image" | "image-to-image" | "text-to-video" | "image-to-video" | "text-to-audio">;
}) => void;
}) => (
<div data-testid="model-dialog" data-filter={props.initialCapabilityFilter} data-title={props.title}>
<button
type="button"
onClick={() =>
props.onModelSelected({
id: "apiyi_seedream5.0",
name: "apiyi_seedream5.0",
provider: "newapiwg",
capabilities: ["text-to-image", "image-to-image"],
})
}
>
Select image model
</button>
<button
type="button"
onClick={() =>
@ -90,7 +103,7 @@ function imageNode(id: string, selected = false, data: Partial<NanoBananaNodeDat
selectedModel: {
provider: "newapiwg",
modelId: "apiyi_nano_banana_2",
displayName: "PopiArt Nano Pro",
displayName: "apiyi_nano_banana_2",
capabilities: ["text-to-image", "image-to-image"],
},
useGoogleSearch: false,
@ -131,8 +144,8 @@ function videoNode(id: string, selected = false, data: Partial<GenerateVideoNode
outputVideo: null,
selectedModel: {
provider: "newapiwg",
modelId: "viduq2-pro-fast",
displayName: "Vidu Q2 Pro Fast",
modelId: "doubao-seedance-2-0-260128",
displayName: "doubao-seedance-2-0-260128",
capabilities: ["text-to-video", "image-to-video"],
},
parameters: {},
@ -315,7 +328,7 @@ describe("GenerationComposer", () => {
fireEvent.click(screen.getByLabelText("Collapse composer"));
expect(screen.queryByDisplayValue("visible prompt")).not.toBeInTheDocument();
expect(screen.getByText("PopiArt Nano Pro")).toBeInTheDocument();
expect(screen.getByText("apiyi_nano_banana_2")).toBeInTheDocument();
expect(screen.getByText("visible prompt")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Expand composer"));
@ -343,6 +356,21 @@ describe("GenerationComposer", () => {
expect(screen.queryByDisplayValue("visible prompt")).not.toBeInTheDocument();
});
it("shows the concrete selected image model name in the composer", async () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true)] });
render(<GenerationComposer />);
fireEvent.click(screen.getByLabelText("Select model"));
fireEvent.click(screen.getByText("Select image model"));
await waitFor(() => {
expect(screen.getByText("apiyi_seedream5.0")).toBeInTheDocument();
});
expect(screen.queryByText("PopiArt Nano Pro")).not.toBeInTheDocument();
expect((useWorkflowStore.getState().nodes[0].data as NanoBananaNodeData).selectedModel?.displayName)
.toBe("apiyi_seedream5.0");
});
it("submits from the collapsed generate button without expanding", async () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
render(<GenerationComposer />);
@ -396,7 +424,7 @@ describe("GenerationComposer", () => {
useWorkflowStore.setState({ nodes: [promptNode("prompt-1", "hello", true)] });
render(<GenerationComposer />);
expect(screen.getByText("GPT-4.1 Mini")).toBeInTheDocument();
expect(screen.getByText("Doubao Seed 2.0 Lite")).toBeInTheDocument();
expect(screen.getByText(/composer only edits the selected generation node/)).toBeInTheDocument();
expect(screen.getByLabelText("Generate")).toBeDisabled();
});

16
src/components/__tests__/LLMGenerateNode.test.tsx

@ -151,6 +151,22 @@ describe("LLMGenerateNode", () => {
expect(screen.getByText("API rate limit exceeded")).toBeInTheDocument();
});
it("should show the switched model display name for provider overload errors", () => {
render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps({
status: "error",
model: "doubao-seed-2-0-pro-260215",
error: "doubao-seed-2-0-lite-260428: provider is temporarily overloaded. Please retry in a moment or switch models.",
})} />
</TestWrapper>
);
expect(screen.getByText(
"Doubao Seed 2.0 Pro: provider is temporarily overloaded. Please retry in a moment or switch models."
)).toBeInTheDocument();
});
it("should show 'Generation failed' when error message is null", () => {
render(
<TestWrapper>

32
src/components/__tests__/ProjectSetupModal.test.tsx

@ -9,6 +9,8 @@ const mockUpdateProviderSettings = vi.fn();
const mockUpdateProviderApiKey = vi.fn();
const mockToggleProvider = vi.fn();
const mockUseWorkflowStore = vi.fn();
const mockSetMaxConcurrentCalls = vi.fn();
const mockUpdateCanvasNavigationSettings = vi.fn();
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: (selector?: (state: unknown) => unknown) => {
@ -56,6 +58,12 @@ const defaultProviderSettings: ProviderSettings = {
},
};
const defaultCanvasNavigationSettings = {
panMode: "space",
zoomMode: "ctrlScroll",
selectionMode: "click",
} as const;
// Default store state factory
const createDefaultState = (overrides = {}) => ({
workflowName: "",
@ -63,6 +71,10 @@ const createDefaultState = (overrides = {}) => ({
saveDirectoryPath: "",
useExternalImageStorage: true,
providerSettings: defaultProviderSettings,
maxConcurrentCalls: 3,
setMaxConcurrentCalls: mockSetMaxConcurrentCalls,
canvasNavigationSettings: defaultCanvasNavigationSettings,
updateCanvasNavigationSettings: mockUpdateCanvasNavigationSettings,
setUseExternalImageStorage: mockSetUseExternalImageStorage,
updateProviderSettings: mockUpdateProviderSettings,
updateProviderApiKey: mockUpdateProviderApiKey,
@ -187,6 +199,26 @@ describe("ProjectSetupModal", () => {
expect(screen.queryByText("Replicate")).not.toBeInTheDocument();
expect(screen.queryByText("fal.ai")).not.toBeInTheDocument();
});
it("should show Popi defaults for new users with no saved node defaults", () => {
render(
<ProjectSetupModal
isOpen={true}
onClose={vi.fn()}
onSave={vi.fn()}
mode="new"
/>
);
fireEvent.click(screen.getByText("Node Defaults"));
expect(screen.getByText("apiyi_nano_banana_2")).toBeInTheDocument();
expect(screen.getByText("doubao-seedance-2-0-260128")).toBeInTheDocument();
expect(screen.getByDisplayValue("Popi Models")).toBeInTheDocument();
expect(screen.getByDisplayValue("Doubao Seed 2.0 Lite")).toBeInTheDocument();
expect(screen.getByText("Temp: 0.7")).toBeInTheDocument();
expect(screen.getByText("Tokens: 8,192")).toBeInTheDocument();
});
});
describe("Project Tab - New Mode", () => {

5
src/components/composer/GenerationComposer.tsx

@ -38,7 +38,7 @@ import {
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { ProviderModel } from "@/lib/providers/types";
import { useI18n } from "@/i18n";
import { DEFAULT_NEWAPIWG_LLM_MODEL_ID } from "@/lib/llmModels";
import { DEFAULT_NEWAPIWG_LLM_MODEL_ID, LLM_MODELS } from "@/lib/llmModels";
type ComposerMode = "empty-create" | "node-edit" | "process-node" | "unsupported-node" | "multi-select";
type ComposerCapability = "all" | "image" | "video" | "3d" | "audio";
@ -85,7 +85,7 @@ const DEFAULT_AUDIO_MODEL = createDefaultAudioModel();
const DEFAULT_PROMPT_TEXT_MODEL: SelectedModel = {
provider: "newapiwg",
modelId: DEFAULT_NEWAPIWG_LLM_MODEL_ID,
displayName: "GPT-4.1 Mini",
displayName: LLM_MODELS.newapiwg.find((model) => model.value === DEFAULT_NEWAPIWG_LLM_MODEL_ID)?.label || DEFAULT_NEWAPIWG_LLM_MODEL_ID,
capabilities: ["text-to-text"],
};
@ -116,7 +116,6 @@ function createPromptDraft(node: WorkflowNode): ComposerDraft {
function getComposerModelLabel(model?: SelectedModel): string {
if (!model) return "Select model";
if (model.provider === "newapiwg" && model.modelId === DEFAULT_IMAGE_MODEL.modelId) return "PopiArt Nano Pro";
return model.displayName || model.modelId;
}

16
src/components/nodes/Generate3DNode.tsx

@ -18,6 +18,8 @@ import { browseRegistry } from "@/utils/browseRegistry";
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel";
import { isPopiProviderMode } from "@/lib/providerMode";
import { useI18n } from "@/i18n";
import { formatUserFacingError } from "@/utils/userFacingErrors";
// 3D generation capabilities
const THREE_D_CAPABILITIES: ModelCapability[] = ["text-to-3d", "image-to-3d"];
@ -25,7 +27,9 @@ const THREE_D_CAPABILITIES: ModelCapability[] = ["text-to-3d", "image-to-3d"];
type Generate3DNodeType = Node<Generate3DNodeData, "generate3d">;
export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeType>) {
const { t } = useI18n();
const nodeData = data;
const displayError = formatUserFacingError(nodeData.error, t, nodeData.selectedModel?.displayName);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const { replicateApiKey, falApiKey, kieApiKey } = useProviderApiKeys();
const popiProviderMode = isPopiProviderMode();
@ -123,11 +127,11 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
// Show toast when error occurs
useEffect(() => {
if (nodeData.status === "error" && prevStatusRef.current !== "error" && nodeData.error) {
useToast.getState().show("3D generation failed", "error", true, nodeData.error);
if (nodeData.status === "error" && prevStatusRef.current !== "error" && displayError) {
useToast.getState().show(t("node.generationFailed"), "error", true, displayError);
}
prevStatusRef.current = nodeData.status;
}, [nodeData.status, nodeData.error]);
}, [nodeData.status, displayError, t]);
const handleClear3D = useCallback(() => {
updateNodeData(id, { output3dUrl: null, savedFilename: null, savedFilePath: null, status: "idle", error: null });
@ -423,7 +427,7 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-white text-xs font-medium">Generation failed</span>
<span className="text-white text-xs font-medium">{t("node.generationFailed")}</span>
<span className="text-white/70 text-[10px]">See toast for details</span>
</div>
)}
@ -452,11 +456,11 @@ export function Generate3DNode({ id, data, selected }: NodeProps<Generate3DNodeT
</svg>
) : nodeData.status === "error" ? (
<span className="text-[10px] text-red-400 text-center px-2">
{nodeData.error || "Failed"}
{displayError || "Failed"}
</span>
) : (
<span className="text-neutral-500 text-[10px]">
Run to generate
{t("node.runToGenerate")}
</span>
)}
</div>

4
src/components/nodes/GenerateAudioNode.tsx

@ -20,12 +20,14 @@ import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel";
import { isPopiProviderMode } from "@/lib/providerMode";
import { useI18n } from "@/i18n";
import { formatUserFacingError } from "@/utils/userFacingErrors";
type GenerateAudioNodeType = Node<GenerateAudioNodeData, "generateAudio">;
export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudioNodeType>) {
const { t } = useI18n();
const nodeData = data;
const displayError = formatUserFacingError(nodeData.error, t, nodeData.selectedModel?.displayName);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const generationsPath = useWorkflowStore((state) => state.generationsPath);
const popiProviderMode = isPopiProviderMode();
@ -465,7 +467,7 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps<GenerateAudi
{nodeData.status === "error" && nodeData.error && (
<div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded text-xs text-red-400">
{nodeData.error}
{displayError}
</div>
)}

20
src/components/nodes/GenerateImageNode.tsx

@ -28,6 +28,7 @@ import {
joinBrowserFileSystemPath,
loadBrowserGenerationFile,
} from "@/utils/browserFileSystem";
import { formatUserFacingError } from "@/utils/userFacingErrors";
/** Reorder items so they read column-first in a row-based CSS grid.
* e.g. [1,2,3,4,5,6,7,8] with 2 cols [1,5,2,6,3,7,4,8] */
@ -462,23 +463,24 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
// Dynamic title based on selected model - just the model name
const displayTitle = useMemo(() => {
if (nodeData.outputImage && nodeData.lastUsedModel?.displayName) {
return nodeData.lastUsedModel.displayName;
}
if (nodeData.selectedModel?.displayName && nodeData.selectedModel.modelId) {
return nodeData.selectedModel.displayName;
}
if (nodeData.outputImage && nodeData.lastUsedModel?.displayName) {
return nodeData.lastUsedModel.displayName;
}
// Fallback for legacy data or no model selected
if (nodeData.model) {
return GEMINI_IMAGE_MODELS.find(m => m.value === nodeData.model)?.label || nodeData.model;
}
return t("node.selectModel");
}, [nodeData.lastUsedModel?.displayName, nodeData.model, nodeData.outputImage, nodeData.selectedModel?.displayName, nodeData.selectedModel?.modelId, t]);
const displayError = formatUserFacingError(nodeData.error, t, displayTitle);
// Provider badge as title prefix
const titlePrefix = useMemo(() => (
<ProviderBadge provider={(nodeData.outputImage && nodeData.lastUsedModel?.provider) || currentProvider} />
), [currentProvider, nodeData.lastUsedModel?.provider, nodeData.outputImage]);
<ProviderBadge provider={nodeData.selectedModel?.provider || (nodeData.outputImage && nodeData.lastUsedModel?.provider) || currentProvider} />
), [currentProvider, nodeData.lastUsedModel?.provider, nodeData.outputImage, nodeData.selectedModel?.provider]);
const selectedModelId = nodeData.selectedModel?.modelId || nodeData.model;
const usesFirstClassImageControls =
@ -527,11 +529,11 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
// Show toast when error occurs
useEffect(() => {
if (nodeData.status === "error" && prevStatusRef.current !== "error" && nodeData.error) {
useToast.getState().show(t("node.generationFailed"), "error", true, nodeData.error);
if (nodeData.status === "error" && prevStatusRef.current !== "error" && displayError) {
useToast.getState().show(t("node.generationFailed"), "error", true, displayError);
}
prevStatusRef.current = nodeData.status;
}, [nodeData.status, nodeData.error, t]);
}, [nodeData.status, displayError, t]);
// Auto-resize node when output image changes
const prevOutputImageRef = useRef<string | null>(null);
@ -934,7 +936,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
</svg>
) : nodeData.status === "error" ? (
<span className="text-[10px] text-red-400 text-center px-2">
{nodeData.error || "Failed"}
{displayError || "Failed"}
</span>
) : (
<span className="text-neutral-500 text-[10px]">

20
src/components/nodes/GenerateVideoNode.tsx

@ -23,6 +23,7 @@ import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel";
import { useI18n } from "@/i18n";
import { getProviderDisplayName, isPopiProviderMode } from "@/lib/providerMode";
import { formatUserFacingError } from "@/utils/userFacingErrors";
// Video generation capabilities
const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video", "video-to-video", "audio-to-video"];
@ -348,19 +349,20 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
// Dynamic title based on selected model - just the model name
const displayTitle = useMemo(() => {
if (nodeData.outputVideo && nodeData.lastUsedModel?.displayName) {
return nodeData.lastUsedModel.displayName;
}
if (nodeData.selectedModel?.displayName && nodeData.selectedModel.modelId) {
return nodeData.selectedModel.displayName;
}
if (nodeData.outputVideo && nodeData.lastUsedModel?.displayName) {
return nodeData.lastUsedModel.displayName;
}
return t("node.selectModel");
}, [nodeData.lastUsedModel?.displayName, nodeData.outputVideo, nodeData.selectedModel?.displayName, nodeData.selectedModel?.modelId, t]);
const displayError = formatUserFacingError(nodeData.error, t, displayTitle);
// Provider badge as title prefix
const titlePrefix = useMemo(() => (
<ProviderBadge provider={(nodeData.outputVideo && nodeData.lastUsedModel?.provider) || currentProvider} />
), [currentProvider, nodeData.lastUsedModel?.provider, nodeData.outputVideo]);
<ProviderBadge provider={nodeData.selectedModel?.provider || (nodeData.outputVideo && nodeData.lastUsedModel?.provider) || currentProvider} />
), [currentProvider, nodeData.lastUsedModel?.provider, nodeData.outputVideo, nodeData.selectedModel?.provider]);
const storageBadge = useMemo(() => {
switch (nodeData.outputVideoStorageStatus) {
@ -384,11 +386,11 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
// Show toast when error occurs
useEffect(() => {
if (nodeData.status === "error" && prevStatusRef.current !== "error" && nodeData.error) {
useToast.getState().show(t("node.videoGenerationFailed"), "error", true, nodeData.error);
if (nodeData.status === "error" && prevStatusRef.current !== "error" && displayError) {
useToast.getState().show(t("node.videoGenerationFailed"), "error", true, displayError);
}
prevStatusRef.current = nodeData.status;
}, [nodeData.status, nodeData.error, t]);
}, [nodeData.status, displayError, t]);
const localizeInputLabel = useCallback((label: string) => {
const normalized = label.toLowerCase();
@ -912,7 +914,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
</svg>
) : nodeData.status === "error" ? (
<span className="text-[10px] text-red-400 text-center px-2">
{nodeData.error || "Failed"}
{displayError || "Failed"}
</span>
) : (
<span className="text-neutral-500 text-[10px]">

19
src/components/nodes/LLMGenerateNode.tsx

@ -11,11 +11,19 @@ import { SettingsTabBar } from "./SettingsTabBar";
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel";
import { LLM_MODELS, LLM_PROVIDERS } from "@/lib/llmModels";
import { useI18n } from "@/i18n";
import { formatUserFacingError } from "@/utils/userFacingErrors";
type LLMGenerateNodeType = Node<LLMGenerateNodeData, "llmGenerate">;
export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNodeType>) {
const { t } = useI18n();
const nodeData = data;
const provider = nodeData.provider || "newapiwg";
const availableModels = LLM_MODELS[provider] || LLM_MODELS.newapiwg;
const selectedModelValue = nodeData.model || availableModels[0].value;
const displayModelName = availableModels.find((model) => model.value === selectedModelValue)?.label || selectedModelValue;
const displayError = formatUserFacingError(nodeData.error, t, displayModelName);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
@ -99,9 +107,6 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
[id, updateNodeData]
);
const provider = nodeData.provider || "newapiwg";
const availableModels = LLM_MODELS[provider] || LLM_MODELS.newapiwg;
return (
<BaseNode
id={id}
@ -121,7 +126,7 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
<SettingsTabBar
activeTab={settingsTab}
onTabChange={setSettingsTab}
primaryLabel={nodeData.model || "Primary"}
primaryLabel={displayModelName || "Primary"}
fallbackLabel={nodeData.fallbackModel.displayName}
/>
)}
@ -314,9 +319,9 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps<LLMGenerateNod
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-white text-xs font-medium">Generation failed</span>
{nodeData.error && (
<span className="text-red-200 text-[10px] text-center px-3 mt-1 line-clamp-3">{nodeData.error}</span>
<span className="text-white text-xs font-medium">{t("node.generationFailed")}</span>
{displayError && (
<span className="text-red-200 text-[10px] text-center px-3 mt-1 line-clamp-3">{displayError}</span>
)}
</div>
) : nodeData.outputText ? (

142
src/components/onboarding/FTUXModelDefaultsStep.tsx

@ -7,6 +7,10 @@ import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { ProviderModel } from "@/lib/providers/types";
import { loadNodeDefaults, saveNodeDefaults } from "@/store/utils/localStorage";
import { useI18n } from "@/i18n";
import {
createNewApiWGDefaultImageModel,
createNewApiWGDefaultVideoModel,
} from "@/store/utils/defaultImageModel";
// Provider icons
const GeminiIcon = () => (
@ -37,8 +41,17 @@ const WaveSpeedIcon = () => (
</svg>
);
const NewApiWGIcon = () => (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 7h16M4 12h16M4 17h16" />
<path d="M8 4v16M16 4v16" />
</svg>
);
const getProviderIcon = (provider: string) => {
switch (provider) {
case "newapiwg":
return <NewApiWGIcon />;
case "gemini":
return <GeminiIcon />;
case "replicate":
@ -64,6 +77,9 @@ export function FTUXModelDefaultsStep({}: FTUXStepProps) {
setLocalDefaults(currentDefaults);
}, []);
const effectiveImageModel = localDefaults.generateImage?.selectedModel ?? createNewApiWGDefaultImageModel();
const effectiveVideoModel = localDefaults.generateVideo?.selectedModel ?? createNewApiWGDefaultVideoModel();
return (
<div className="py-6 px-6">
<h3 className="text-lg font-semibold text-neutral-100 mb-2">
@ -79,45 +95,30 @@ export function FTUXModelDefaultsStep({}: FTUXStepProps) {
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-100">{t("ftux.defaultImageModel")}</span>
<div className="flex items-center gap-2">
{localDefaults.generateImage?.selectedModel ? (
<>
<div className="flex items-center gap-1.5 text-xs text-neutral-300">
{getProviderIcon(localDefaults.generateImage.selectedModel.provider)}
<span className="truncate max-w-[150px]">
{localDefaults.generateImage.selectedModel.displayName}
</span>
</div>
<button
type="button"
onClick={() => setShowImageDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.change")}
</button>
<button
type="button"
onClick={() => {
const { generateImage, ...rest } = localDefaults;
setLocalDefaults(rest);
saveNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
</>
) : (
<>
<span className="text-xs text-neutral-400">{t("ftux.noneSet")}</span>
<button
type="button"
onClick={() => setShowImageDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.select")}
</button>
</>
)}
<div className="flex items-center gap-1.5 text-xs text-neutral-300">
{getProviderIcon(effectiveImageModel.provider)}
<span className="truncate max-w-[150px]">
{effectiveImageModel.displayName}
</span>
</div>
<button
type="button"
onClick={() => setShowImageDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.change")}
</button>
<button
type="button"
onClick={() => {
const { generateImage, ...rest } = localDefaults;
setLocalDefaults(rest);
saveNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
</div>
</div>
</div>
@ -127,45 +128,30 @@ export function FTUXModelDefaultsStep({}: FTUXStepProps) {
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-100">{t("ftux.defaultVideoModel")}</span>
<div className="flex items-center gap-2">
{localDefaults.generateVideo?.selectedModel ? (
<>
<div className="flex items-center gap-1.5 text-xs text-neutral-300">
{getProviderIcon(localDefaults.generateVideo.selectedModel.provider)}
<span className="truncate max-w-[150px]">
{localDefaults.generateVideo.selectedModel.displayName}
</span>
</div>
<button
type="button"
onClick={() => setShowVideoDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.change")}
</button>
<button
type="button"
onClick={() => {
const { generateVideo, ...rest } = localDefaults;
setLocalDefaults(rest);
saveNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
</>
) : (
<>
<span className="text-xs text-neutral-400">{t("ftux.noneSet")}</span>
<button
type="button"
onClick={() => setShowVideoDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.select")}
</button>
</>
)}
<div className="flex items-center gap-1.5 text-xs text-neutral-300">
{getProviderIcon(effectiveVideoModel.provider)}
<span className="truncate max-w-[150px]">
{effectiveVideoModel.displayName}
</span>
</div>
<button
type="button"
onClick={() => setShowVideoDialog(true)}
className="px-2 py-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
{t("common.change")}
</button>
<button
type="button"
onClick={() => {
const { generateVideo, ...rest } = localDefaults;
setLocalDefaults(rest);
saveNodeDefaults(rest);
}}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{t("common.clear")}
</button>
</div>
</div>
</div>

12
src/i18n/index.tsx

@ -47,6 +47,7 @@ const en = {
"common.previous": "Previous",
"common.nextItem": "Next",
"common.processing": "Processing...",
"error.providerOverloaded": "{model}: provider is temporarily overloaded. Please retry in a moment or switch models.",
"node.optionalTitle": "This input is optional - empty inputs will be skipped",
"node.markOptionalTitle": "Mark as optional - empty inputs will skip this branch",
"header.openWelcome": "Open welcome screen",
@ -538,7 +539,7 @@ const en = {
"project.systemImageDefault": "System default (Popi Models)",
"project.noneSetFirstUse": "None set (select on first use)",
"project.defaultLLM": "Default LLM Settings",
"project.systemLLMDefault": "Using system defaults (Popi Models GPT-4.1 Mini)",
"project.systemLLMDefault": "Using system defaults (Popi Models Doubao Seed 2.0 Lite)",
"project.provider": "Provider",
"project.model": "Model",
"project.temp": "Temp: {value}",
@ -659,6 +660,7 @@ const zhCN: Dictionary = {
"common.previous": "上一个",
"common.nextItem": "下一个",
"common.processing": "处理中...",
"error.providerOverloaded": "{model}:服务商暂时过载。请稍后重试,或切换到其他模型。",
"node.optionalTitle": "此输入为可选,空输入会被跳过",
"node.markOptionalTitle": "标记为可选,空输入会跳过这个分支",
"header.openWelcome": "打开欢迎页",
@ -1148,7 +1150,7 @@ const zhCN: Dictionary = {
"project.systemImageDefault": "系统默认(Popi Models)",
"project.noneSetFirstUse": "未设置(首次使用时选择)",
"project.defaultLLM": "默认 LLM 设置",
"project.systemLLMDefault": "使用系统默认值(Popi Models GPT-4.1 Mini)",
"project.systemLLMDefault": "使用系统默认值(Popi Models Doubao Seed 2.0 Lite)",
"project.provider": "服务商",
"project.model": "模型",
"project.temp": "温度:{value}",
@ -1283,6 +1285,7 @@ const zhTW: Dictionary = {
"common.previous": "上一個",
"common.nextItem": "下一個",
"common.processing": "處理中...",
"error.providerOverloaded": "{model}:服務商暫時過載。請稍後重試,或切換到其他模型。",
"node.optionalTitle": "此輸入為可選,空輸入會被略過",
"node.markOptionalTitle": "標記為可選,空輸入會略過這個分支",
"header.openWelcome": "開啟歡迎頁",
@ -1670,7 +1673,7 @@ const zhTW: Dictionary = {
"project.newapiwgDesc": "Popi 統一模型閘道",
"project.baseUrl": "Base URL",
"project.defaultLLM": "預設 LLM 設定",
"project.systemLLMDefault": "使用系統預設值(Popi Models GPT-4.1 Mini)",
"project.systemLLMDefault": "使用系統預設值(Popi Models Doubao Seed 2.0 Lite)",
"project.executionSettings": "執行設定",
"project.maxParallel": "最大平行呼叫:{count}",
"project.canvasHelp": "設定畫布導覽和互動方式。",
@ -1746,6 +1749,7 @@ const ja: Dictionary = {
"common.previous": "前へ",
"common.nextItem": "次へ",
"common.processing": "処理中...",
"error.providerOverloaded": "{model}: プロバイダーが一時的に混雑しています。少し待って再試行するか、別のモデルに切り替えてください。",
"node.optionalTitle": "この入力は任意です。空の入力はスキップされます",
"node.markOptionalTitle": "任意に設定します。空の入力はこの分岐をスキップします",
"header.openWelcome": "ウェルカム画面を開く",
@ -2181,7 +2185,7 @@ const ja: Dictionary = {
"project.systemImageDefault": "システム既定(Popi Models)",
"project.noneSetFirstUse": "未設定(初回使用時に選択)",
"project.defaultLLM": "既定の LLM 設定",
"project.systemLLMDefault": "システム既定値を使用(Popi Models GPT-4.1 Mini)",
"project.systemLLMDefault": "システム既定値を使用(Popi Models Doubao Seed 2.0 Lite)",
"project.provider": "プロバイダー",
"project.model": "モデル",
"project.temp": "温度: {value}",

12
src/lib/__tests__/llmModels.test.ts

@ -2,15 +2,11 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_NEWAPIWG_LLM_MODEL_ID, LLM_MODELS } from "@/lib/llmModels";
describe("LLM model options", () => {
it("defaults NewApiWG text generation to GPT-4.1 Mini", () => {
expect(DEFAULT_NEWAPIWG_LLM_MODEL_ID).toBe("gpt-4.1-mini");
it("defaults NewApiWG text generation to Doubao Seed 2.0 Lite", () => {
expect(DEFAULT_NEWAPIWG_LLM_MODEL_ID).toBe("doubao-seed-2-0-lite-260428");
});
it("includes Doubao Seed 2.0 for NewApiWG selectors", () => {
expect(LLM_MODELS.newapiwg).toContainEqual({
value: "gpt-4.1-mini",
label: "GPT-4.1 Mini",
});
expect(LLM_MODELS.newapiwg).toContainEqual({
value: "doubao-seed-2-0-lite-260428",
label: "Doubao Seed 2.0 Lite",
@ -19,5 +15,9 @@ describe("LLM model options", () => {
value: "doubao-seed-2-0-pro-260215",
label: "Doubao Seed 2.0 Pro",
});
expect(LLM_MODELS.newapiwg).toContainEqual({
value: "gpt-4.1-mini",
label: "GPT-4.1 Mini",
});
});
});

2
src/lib/chat/tools.ts

@ -42,7 +42,7 @@ AI video generation. Takes image + text inputs, outputs video. Use Popi Models f
### LLM Text Generation
AI text generation for expanding prompts or analyzing images.
- **Provider dropdown**: Popi Models
- **Model dropdown**: GPT-4.1 Mini (Popi Models default), Doubao Seed 2.0 Lite/Pro (Popi Models options)
- **Model dropdown**: Doubao Seed 2.0 Lite (Popi Models default), Doubao Seed 2.0 Pro and GPT-4.1 Mini (Popi Models options)
- **Parameters** (collapsible): Temperature slider (0-2), Max Tokens slider (256-16384)
- Takes **text** input (required), optional **image** input

4
src/lib/llmModels.ts

@ -6,7 +6,7 @@ export interface LLMModelOption {
label: string;
}
export const DEFAULT_NEWAPIWG_LLM_MODEL_ID: LLMModelType = "gpt-4.1-mini";
export const DEFAULT_NEWAPIWG_LLM_MODEL_ID: LLMModelType = "doubao-seed-2-0-lite-260428";
const ALL_LLM_PROVIDERS: { value: LLMProvider; label: string }[] = [
{ value: "newapiwg", label: getProviderDisplayName("newapiwg") },
@ -22,9 +22,9 @@ export const LLM_PROVIDERS: { value: LLMProvider; label: string }[] =
export const LLM_MODELS: Record<LLMProvider, LLMModelOption[]> = {
newapiwg: [
{ value: "gpt-4.1-mini", label: "GPT-4.1 Mini" },
{ value: "doubao-seed-2-0-lite-260428", label: "Doubao Seed 2.0 Lite" },
{ value: "doubao-seed-2-0-pro-260215", label: "Doubao Seed 2.0 Pro" },
{ value: "gpt-4.1-mini", label: "GPT-4.1 Mini" },
],
google: [
{ value: "gemini-3-flash-preview", label: "Gemini 3 Flash" },

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

@ -214,8 +214,8 @@ describe("executeGenerateVideo", () => {
},
fallbackModel: {
provider: "newapiwg",
modelId: "viduq2-pro-fast",
displayName: "Vidu Q2 Pro Fast",
modelId: "doubao-seedance-2-0-260128",
displayName: "doubao-seedance-2-0-260128",
},
fallbackParameters: { duration: 5 },
});
@ -239,13 +239,13 @@ describe("executeGenerateVideo", () => {
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "viduq2-pro-fast",
modelId: "doubao-seedance-2-0-260128",
});
expect(body.parameters).toEqual({ duration: 5 });
expect(ctx.updateNodeData).toHaveBeenCalledWith("vid-1", expect.objectContaining({
selectedModel: expect.objectContaining({
provider: "newapiwg",
modelId: "viduq2-pro-fast",
modelId: "doubao-seedance-2-0-260128",
}),
parameters: { duration: 5 },
fallbackModel: undefined,
@ -280,7 +280,7 @@ describe("executeGenerateVideo", () => {
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "viduq2-pro-fast",
modelId: "doubao-seedance-2-0-260128",
});
});
@ -315,7 +315,7 @@ describe("executeGenerateVideo", () => {
const body = JSON.parse(request[1].body);
expect(body.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "viduq2-pro-fast",
modelId: "doubao-seedance-2-0-260128",
});
expect(request[1].headers["X-NewApiWG-Key"]).toBeUndefined();
});

8
src/store/utils/__tests__/nodeDefaults.test.ts

@ -278,8 +278,8 @@ describe("nodeDefaults utilities", () => {
expect((data as any).selectedModel).toEqual({
provider: "newapiwg",
modelId: "viduq2-pro-fast",
displayName: "Vidu Q2 Pro Fast",
modelId: "doubao-seedance-2-0-260128",
displayName: "doubao-seedance-2-0-260128",
capabilities: ["text-to-video", "image-to-video"],
});
});
@ -310,7 +310,7 @@ describe("nodeDefaults utilities", () => {
const data = createDefaultNodeData("llmGenerate");
expect((data as any).provider).toBe("newapiwg");
expect((data as any).model).toBe("gpt-4.1-mini");
expect((data as any).model).toBe("doubao-seed-2-0-lite-260428");
expect((data as any).temperature).toBe(0.7);
expect((data as any).maxTokens).toBe(8192);
});
@ -332,7 +332,7 @@ describe("nodeDefaults utilities", () => {
expect((data as any).temperature).toBe(0.9);
// Should fall back to NewApiWG defaults for others
expect((data as any).provider).toBe("newapiwg");
expect((data as any).model).toBe("gpt-4.1-mini");
expect((data as any).model).toBe("doubao-seed-2-0-lite-260428");
expect((data as any).maxTokens).toBe(8192);
});
});

6
src/store/utils/defaultImageModel.ts

@ -2,14 +2,14 @@ import type { ModelType, ProviderEnvStatus, ProviderSettings, ProviderType, Sele
import { MODEL_DISPLAY_NAMES } from "@/types";
const NEWAPIWG_DEFAULT_IMAGE_MODEL_ID = "apiyi_nano_banana_2";
const NEWAPIWG_DEFAULT_VIDEO_MODEL_ID = "viduq2-pro-fast";
const NEWAPIWG_DEFAULT_VIDEO_MODEL_ID = "doubao-seedance-2-0-260128";
const DEFAULT_AUDIO_MODEL_ID = "elevenlabs/turbo-v2.5";
export function createNewApiWGDefaultImageModel(): SelectedModel {
return {
provider: "newapiwg",
modelId: NEWAPIWG_DEFAULT_IMAGE_MODEL_ID,
displayName: "APIYI Nano Banana 2",
displayName: NEWAPIWG_DEFAULT_IMAGE_MODEL_ID,
capabilities: ["text-to-image", "image-to-image"],
};
}
@ -18,7 +18,7 @@ export function createNewApiWGDefaultVideoModel(): SelectedModel {
return {
provider: "newapiwg",
modelId: NEWAPIWG_DEFAULT_VIDEO_MODEL_ID,
displayName: "Vidu Q2 Pro Fast",
displayName: NEWAPIWG_DEFAULT_VIDEO_MODEL_ID,
capabilities: ["text-to-video", "image-to-video"],
};
}

31
src/utils/__tests__/userFacingErrors.test.ts

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import type { TranslationKey } from "@/i18n";
import { formatUserFacingError } from "@/utils/userFacingErrors";
const t = (key: TranslationKey, values?: Record<string, string | number>) => {
if (key === "error.providerOverloaded") {
return `${values?.model}: localized overload`;
}
return key;
};
describe("formatUserFacingError", () => {
it("localizes normalized provider overload errors", () => {
expect(formatUserFacingError(
"apiyi_nano_banana_pro: provider is temporarily overloaded. Please retry in a moment or switch models.",
t
)).toBe("apiyi_nano_banana_pro: localized overload");
});
it("uses the currently displayed model name when provided", () => {
expect(formatUserFacingError(
"apiyi_nano_banana_pro: provider is temporarily overloaded. Please retry in a moment or switch models.",
t,
"PopiArt Nano Pro"
)).toBe("PopiArt Nano Pro: localized overload");
});
it("preserves unknown upstream errors", () => {
expect(formatUserFacingError("unexpected upstream failure", t)).toBe("unexpected upstream failure");
});
});

23
src/utils/userFacingErrors.ts

@ -0,0 +1,23 @@
import type { TranslationKey } from "@/i18n";
type Translate = (key: TranslationKey, values?: Record<string, string | number>) => string;
const PROVIDER_OVERLOADED_RE =
/^(.*?): provider is temporarily overloaded\. Please retry in a moment or switch models\.$/i;
export function formatUserFacingError(
message: string | null | undefined,
t: Translate,
modelDisplayName?: string | null
): string | null {
if (!message) return null;
const overloadedMatch = message.match(PROVIDER_OVERLOADED_RE);
if (overloadedMatch) {
return t("error.providerOverloaded", {
model: modelDisplayName?.trim() || (overloadedMatch[1] ?? "Model").trim(),
});
}
return message;
}
Loading…
Cancel
Save