Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,794 changes: 5,794 additions & 0 deletions cli-manifest.json

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions clis/maybeai-image-app/apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { listMaybeAiGeneratedImageApps } from './catalog.js';
import { inferMaybeAiImageKind } from './resolver.js';

cli({
site: 'maybeai-image-app',
name: 'apps',
description: 'List normalized MaybeAI generated-image apps and their unified CLI fields',
strategy: Strategy.PUBLIC,
browser: false,
args: [],
columns: ['group', 'app', 'kind', 'title', 'inputs', 'output'],
func: async () => {
return listMaybeAiGeneratedImageApps().map((app) => ({
group: app.group,
app: app.id,
kind: inferMaybeAiImageKind(app.id),
title: app.title,
inputs: app.fields.map((field) => field.key).join(', '),
output: app.output.multiple ? 'images[]' : 'image',
}));
},
});
496 changes: 496 additions & 0 deletions clis/maybeai-image-app/catalog.ts

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions clis/maybeai-image-app/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
import { getMaybeAiGeneratedImageApp } from './catalog.js';
import { readJsonObjectInput, mergeDefinedCliValues } from './input.js';
import { MAYBEAI_IMAGE_KINDS } from './profiles.js';
import { resolveMaybeAiGeneratedImageInput } from './resolver.js';
import { getMaybeAiWorkflowProfile } from './workflow-profiles.js';
import {
MaybeAiWorkflowClient,
buildSecondStepVariablesV2,
extractGeneratedImages,
readMaybeAiWorkflowClientOptions,
} from './workflow-client.js';

cli({
site: 'maybeai-image-app',
name: 'generate',
description: 'Generate images by running MaybeAI workflows; prompt generation is handled internally',
strategy: Strategy.PUBLIC,
browser: false,
defaultFormat: 'json',
args: [
{ name: 'app', positional: true, required: true, help: 'MaybeAI app id, e.g. gen-main' },
{ name: 'json', help: 'Inline JSON payload using normalized CLI keys' },
{ name: 'file', help: 'Path to a JSON payload file' },
{ name: 'platform', help: 'Target platform, e.g. Amazon, Shopee, XiaoHongShu' },
{ name: 'image-kind', choices: [...MAYBEAI_IMAGE_KINDS], help: 'Image kind for platform ratio adaptation' },
{ name: 'market', help: 'Target country/region' },
{ name: 'category', help: 'Product category' },
{ name: 'ratio', help: 'Override aspect ratio' },
{ name: 'resolution', help: 'Override resolution' },
{ name: 'engine', help: 'Override Shell image model' },
{ name: 'prompt', help: 'Extra generation requirements' },
{ name: 'task-id', help: 'Optional workflow task id for tracing' },
],
func: async (_page, kwargs) => {
const appId = String(kwargs.app);
const app = getMaybeAiGeneratedImageApp(appId);
const workflow = getMaybeAiWorkflowProfile(appId);
const baseInput = readJsonObjectInput(
typeof kwargs.file === 'string' ? kwargs.file : undefined,
typeof kwargs.json === 'string' ? kwargs.json : undefined,
);
const input = mergeDefinedCliValues(baseInput, kwargs, [
'platform',
'market',
'category',
'ratio',
'resolution',
'engine',
'prompt',
]);

if (typeof kwargs['image-kind'] === 'string') {
input.imageKind = kwargs['image-kind'];
}

const resolved = resolveMaybeAiGeneratedImageInput(appId, input);
const client = new MaybeAiWorkflowClient(readMaybeAiWorkflowClientOptions());
const taskId = typeof kwargs['task-id'] === 'string' ? kwargs['task-id'] : undefined;

const rawResults = workflow.mode === 'direct'
? await client.run({
artifactId: workflow.resultArtifactId,
variables: resolved.variables,
appId,
title: app.title,
taskId,
service: workflow.service,
})
: await runTwoStepWorkflow(client, {
appId,
title: app.title,
taskId,
promptArtifactId: workflow.promptArtifactId,
resultArtifactId: workflow.resultArtifactId,
variables: resolved.variables,
includeLlmModel: app.fields.some((field) => field.backendVariable === 'variable:scalar:llm_model'),
service: workflow.service,
});

const images = extractGeneratedImages(rawResults, app.output.backendFields);
if (images.length === 0) {
throw new CliError('EMPTY_RESULT', 'Workflow completed but no generated image URL was found', JSON.stringify(rawResults).slice(0, 1000));
}

return {
app: app.id,
title: app.title,
mode: workflow.mode,
images,
resolvedInput: resolved.input,
modelProfile: resolved.modelProfile,
warnings: resolved.warnings,
};
},
});

async function runTwoStepWorkflow(
client: MaybeAiWorkflowClient,
options: {
appId: string;
title: string;
taskId?: string;
promptArtifactId: string;
resultArtifactId: string;
variables: Array<{ name: string; default_value: unknown }>;
includeLlmModel: boolean;
service: string;
},
): Promise<unknown[]> {
const promptTaskId = crypto.randomUUID();
const promptConfigs = await client.run({
artifactId: options.promptArtifactId,
variables: options.variables,
appId: options.appId,
title: options.title,
taskId: promptTaskId,
useSystemAuth: true,
service: options.service,
});

const secondStepVariables = buildSecondStepVariablesV2(
promptConfigs.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object' && !Array.isArray(item)),
options.variables,
options.appId,
options.includeLlmModel,
);

return client.run({
artifactId: options.resultArtifactId,
variables: secondStepVariables,
appId: options.appId,
title: options.title,
taskId: options.taskId,
prevTaskId: promptTaskId,
service: options.service,
});
}
39 changes: 39 additions & 0 deletions clis/maybeai-image-app/input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as fs from 'node:fs';
import { ArgumentError } from '@jackwener/opencli/errors';

export function readJsonObjectInput(filePath: string | undefined, rawJson: string | undefined, options: { required?: boolean } = {}): Record<string, unknown> {
const required = options.required ?? true;

if (filePath && rawJson) {
throw new ArgumentError('Use either --file or --json, not both');
}
if (!filePath && !rawJson) {
if (!required) return {};
throw new ArgumentError('Missing payload input', 'Pass --json \'{"product":"..."}\' or --file payload.json');
}

const source = filePath
? fs.readFileSync(filePath, 'utf8')
: rawJson!;

try {
const parsed = JSON.parse(source) as Record<string, unknown>;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Payload must be a JSON object');
}
return parsed;
} catch (error) {
throw new ArgumentError(`Invalid JSON payload: ${error instanceof Error ? error.message : String(error)}`);
}
}

export function mergeDefinedCliValues(input: Record<string, unknown>, kwargs: Record<string, unknown>, keys: string[]): Record<string, unknown> {
const merged = { ...input };
for (const key of keys) {
const value = kwargs[key];
if (value !== undefined && value !== null && value !== '') {
merged[key] = value;
}
}
return merged;
}
178 changes: 178 additions & 0 deletions clis/maybeai-image-app/model-profiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { ArgumentError } from '@jackwener/opencli/errors';
import {
MAYBEAI_DEFAULT_IMAGE_MODEL_PRIORITY,
type MaybeAiAspectRatio,
type MaybeAiImageModel,
type MaybeAiResolution,
} from './profiles.js';

export interface MaybeAiModelRuleSource {
label: string;
url?: string;
confidence: 'official' | 'inferred';
}

export interface MaybeAiImageModelProfile {
model: MaybeAiImageModel;
priority: number | null;
defaultRatio: MaybeAiAspectRatio;
supportedRatios: MaybeAiAspectRatio[];
supportedResolutions?: MaybeAiResolution[];
notes: string[];
sources: MaybeAiModelRuleSource[];
}

const STANDARD_IMAGE_RATIOS: MaybeAiAspectRatio[] = [
'auto',
'21:9',
'16:9',
'3:2',
'4:3',
'5:4',
'1:1',
'4:5',
'3:4',
'2:3',
'9:16',
];

const GEMINI_FLASH_RATIOS: MaybeAiAspectRatio[] = [
...STANDARD_IMAGE_RATIOS,
'4:1',
'1:4',
'8:1',
'1:8',
];

export const MAYBEAI_IMAGE_MODEL_PROFILES: Record<MaybeAiImageModel, MaybeAiImageModelProfile> = {
'google/gemini-3.1-flash-image-preview': {
model: 'google/gemini-3.1-flash-image-preview',
priority: 1,
defaultRatio: '1:1',
supportedRatios: GEMINI_FLASH_RATIOS,
supportedResolutions: ['1K', '2K', '4K'],
notes: ['默认首选模型;官方 Gemini image config 支持标准比例和 4:1/1:4/8:1/1:8 等扩展比例。'],
sources: [
{
label: 'Google Gemini API image generation ImageConfig',
url: 'https://ai.google.dev/api/generate-content',
confidence: 'official',
},
],
},
'fal-ai/nano-banana-2/edit': {
model: 'fal-ai/nano-banana-2/edit',
priority: 2,
defaultRatio: '1:1',
supportedRatios: GEMINI_FLASH_RATIOS,
supportedResolutions: ['1K', '2K', '4K'],
notes: ['第二优先级;fal 官方 schema 暴露的 aspect_ratio 与 Gemini Flash 族比例保持一致。'],
sources: [
{
label: 'fal.ai nano-banana-2 edit API schema',
url: 'https://fal.ai/models/fal-ai/nano-banana-2/edit/api',
confidence: 'official',
},
],
},
'google/gemini-3-pro-image-preview': {
model: 'google/gemini-3-pro-image-preview',
priority: 3,
defaultRatio: '1:1',
supportedRatios: STANDARD_IMAGE_RATIOS,
supportedResolutions: ['1K', '2K', '4K'],
notes: ['第三优先级;用于 Shell 中部分固定为 Pro 的编辑类 app。'],
sources: [
{
label: 'Google Gemini API image generation ImageConfig',
url: 'https://ai.google.dev/api/generate-content',
confidence: 'official',
},
],
},
'fal-ai/nano-banana-pro/edit': {
model: 'fal-ai/nano-banana-pro/edit',
priority: 4,
defaultRatio: '1:1',
supportedRatios: STANDARD_IMAGE_RATIOS,
supportedResolutions: ['1K', '2K', '4K'],
notes: ['第四优先级;fal 官方 schema 支持电商常用标准比例。'],
sources: [
{
label: 'fal.ai nano-banana-pro edit API schema',
url: 'https://fal.ai/models/fal-ai/nano-banana-pro/edit/api',
confidence: 'official',
},
],
},
'fal-ai/gpt-image-1.5/edit': {
model: 'fal-ai/gpt-image-1.5/edit',
priority: null,
defaultRatio: '1:1',
supportedRatios: STANDARD_IMAGE_RATIOS,
supportedResolutions: ['1K', '2K', '4K'],
notes: ['Shell 支持模型,但不在 MaybeAI 默认优先级中;比例按 Shell/Fal 常用标准比例保守处理。'],
sources: [
{
label: 'MaybeAI Shell image model option',
confidence: 'inferred',
},
],
},
'fal-ai/qwen-image-edit-2511': {
model: 'fal-ai/qwen-image-edit-2511',
priority: null,
defaultRatio: '1:1',
supportedRatios: STANDARD_IMAGE_RATIOS,
supportedResolutions: ['1K', '2K', '4K'],
notes: ['Shell 支持模型,但不在 MaybeAI 默认优先级中;比例按 Shell/Fal 常用标准比例保守处理。'],
sources: [
{
label: 'MaybeAI Shell image model option',
confidence: 'inferred',
},
],
},
};

export function getMaybeAiImageModelProfile(model: MaybeAiImageModel): MaybeAiImageModelProfile {
return MAYBEAI_IMAGE_MODEL_PROFILES[model];
}

export function listMaybeAiImageModelProfiles(): MaybeAiImageModelProfile[] {
return Object.values(MAYBEAI_IMAGE_MODEL_PROFILES).sort((left, right) => {
const leftPriority = left.priority ?? Number.MAX_SAFE_INTEGER;
const rightPriority = right.priority ?? Number.MAX_SAFE_INTEGER;
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
return left.model.localeCompare(right.model);
});
}

export function supportsMaybeAiRatio(model: MaybeAiImageModel, ratio: MaybeAiAspectRatio | undefined): boolean {
if (!ratio || ratio === 'auto') return true;
return getMaybeAiImageModelProfile(model).supportedRatios.includes(ratio);
}

export function selectMaybeAiDefaultModelForRatio(ratio: MaybeAiAspectRatio | undefined): MaybeAiImageModel {
const model = MAYBEAI_DEFAULT_IMAGE_MODEL_PRIORITY.find((candidate) => supportsMaybeAiRatio(candidate, ratio));
if (!model) {
throw new ArgumentError(
`No default image model supports ratio: ${ratio}`,
`Try one of the supported ratios for default models: ${listMaybeAiImageModelProfiles()
.filter((profile) => profile.priority !== null)
.flatMap((profile) => profile.supportedRatios)
.filter((item, index, list) => list.indexOf(item) === index)
.join(', ')}`,
);
}
return model;
}

export function assertMaybeAiModelSupportsRatio(model: MaybeAiImageModel, ratio: MaybeAiAspectRatio | undefined): void {
if (supportsMaybeAiRatio(model, ratio)) return;
const profile = getMaybeAiImageModelProfile(model);
throw new ArgumentError(
`Model ${model} does not support ratio ${ratio}`,
`Supported ratios for ${model}: ${profile.supportedRatios.join(', ')}`,
);
}
Loading