2026-10-28 01:20:01 +08:00
|
|
|
import { BoothConfig, BoothStyle } from "@/types/booth";
|
|
|
|
|
import { generateWithGemini } from "./gemini";
|
|
|
|
|
import { generateWithOpenRouter } from "./openrouter";
|
|
|
|
|
import {
|
|
|
|
|
isProviderAvailable,
|
|
|
|
|
triggerCooldown,
|
|
|
|
|
resetProvider,
|
|
|
|
|
} from "./circuit-breaker";
|
|
|
|
|
import { ContentSafetyError, isContentSafetyError } from "./safety";
|
|
|
|
|
|
|
|
|
|
export interface OrchestrationRequest {
|
|
|
|
|
config: BoothConfig;
|
|
|
|
|
style: BoothStyle;
|
|
|
|
|
imageBase64: string;
|
|
|
|
|
mimeType?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface OrchestrationResult {
|
|
|
|
|
imageUrl: string;
|
|
|
|
|
providerUsed: "gemini" | "openrouter" | "demo_mock";
|
|
|
|
|
modelUsed: string;
|
|
|
|
|
durationMs: number;
|
|
|
|
|
fallbackTriggered: boolean;
|
|
|
|
|
warnings?: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function orchestrateGeneration(
|
|
|
|
|
request: OrchestrationRequest
|
|
|
|
|
): Promise<OrchestrationResult> {
|
|
|
|
|
const startTime = Date.now();
|
|
|
|
|
const { config, style, imageBase64 } = request;
|
|
|
|
|
const mimeType = request.mimeType || "image/jpeg";
|
|
|
|
|
const warnings: string[] = [];
|
|
|
|
|
|
|
|
|
|
const hasGeminiKey = Boolean(process.env.GEMINI_API_KEY);
|
|
|
|
|
const hasOpenRouterKey = Boolean(process.env.OPENROUTER_API_KEY);
|
|
|
|
|
|
|
|
|
|
// Demo / Offline Mode fallback when no API keys are provided yet
|
|
|
|
|
if (!hasGeminiKey && !hasOpenRouterKey) {
|
|
|
|
|
console.log("[AI Orchestrator] No API keys configured. Using Demo Mode sample image.");
|
|
|
|
|
// Simulate generation delay (2 seconds)
|
|
|
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
|
|
|
return {
|
|
|
|
|
imageUrl: style.sampleResult || style.thumbnail || "/assets/styles/sample_clay_pink.png",
|
|
|
|
|
providerUsed: "demo_mock",
|
|
|
|
|
modelUsed: "demo-simulator",
|
|
|
|
|
durationMs: Date.now() - startTime,
|
|
|
|
|
fallbackTriggered: false,
|
|
|
|
|
warnings: ["Demo Mode active: Set GEMINI_API_KEY or OPENROUTER_API_KEY in .env.local to enable live AI generation."],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-28 10:26:41 +08:00
|
|
|
// 1. Try Primary Provider (OpenRouter)
|
|
|
|
|
if (isProviderAvailable("openrouter") && hasOpenRouterKey) {
|
|
|
|
|
try {
|
|
|
|
|
console.log(`[AI Orchestrator] Attempting Primary (OpenRouter: ${config.ai.backup.model})...`);
|
|
|
|
|
const result = await generateWithOpenRouter({
|
|
|
|
|
model: config.ai.backup.model,
|
|
|
|
|
prompt: style.prompt,
|
|
|
|
|
negativePrompt: style.negativePrompt,
|
|
|
|
|
imageBase64,
|
|
|
|
|
mimeType,
|
|
|
|
|
timeoutMs: config.ai.backup.timeoutMs,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
resetProvider("openrouter");
|
|
|
|
|
return {
|
|
|
|
|
imageUrl: result.imageUrl,
|
|
|
|
|
providerUsed: "openrouter",
|
|
|
|
|
modelUsed: result.model,
|
|
|
|
|
durationMs: Date.now() - startTime,
|
|
|
|
|
fallbackTriggered: false,
|
|
|
|
|
};
|
|
|
|
|
} catch (primaryError: any) {
|
|
|
|
|
if (isContentSafetyError(primaryError)) {
|
|
|
|
|
console.warn(`[AI Orchestrator] Primary provider (OpenRouter) rejected image for safety:`, primaryError.message);
|
|
|
|
|
throw new ContentSafetyError(primaryError.message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.warn(`[AI Orchestrator] Primary provider (OpenRouter) failed:`, primaryError.message);
|
|
|
|
|
warnings.push(`Primary (OpenRouter) failed: ${primaryError.message}`);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
warnings.push("Primary provider key (OpenRouter) missing or in cooldown. Skipping to fallback.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Try Fallback Provider (Google Gemini)
|
2026-10-28 01:20:01 +08:00
|
|
|
if (isProviderAvailable("gemini") && hasGeminiKey) {
|
|
|
|
|
try {
|
2026-10-28 10:26:41 +08:00
|
|
|
console.log(`[AI Orchestrator] Attempting Fallback (Gemini: ${config.ai.primary.model})...`);
|
2026-10-28 01:20:01 +08:00
|
|
|
const result = await generateWithGemini({
|
|
|
|
|
model: config.ai.primary.model,
|
|
|
|
|
prompt: style.prompt,
|
|
|
|
|
negativePrompt: style.negativePrompt,
|
|
|
|
|
imageBase64,
|
|
|
|
|
mimeType,
|
|
|
|
|
timeoutMs: config.ai.primary.timeoutMs,
|
|
|
|
|
enableSfwCheck: config.ai.enableSfwCheck,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
resetProvider("gemini");
|
|
|
|
|
return {
|
|
|
|
|
imageUrl: result.imageUrl,
|
|
|
|
|
providerUsed: "gemini",
|
|
|
|
|
modelUsed: result.model,
|
|
|
|
|
durationMs: Date.now() - startTime,
|
|
|
|
|
fallbackTriggered: true,
|
|
|
|
|
warnings,
|
|
|
|
|
};
|
2026-10-28 10:26:41 +08:00
|
|
|
} catch (fallbackError: any) {
|
|
|
|
|
if (isContentSafetyError(fallbackError)) {
|
|
|
|
|
console.warn(`[AI Orchestrator] Fallback provider (Gemini) rejected image for safety:`, fallbackError.message);
|
|
|
|
|
throw new ContentSafetyError(fallbackError.message);
|
2026-10-28 01:20:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-10-28 10:26:41 +08:00
|
|
|
console.error(`[AI Orchestrator] Fallback provider (Gemini) failed:`, fallbackError.message);
|
|
|
|
|
warnings.push(`Fallback (Gemini) failed: ${fallbackError.message}`);
|
2026-10-28 01:20:01 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If both providers failed or were unavailable, throw descriptive error
|
|
|
|
|
throw new Error(
|
|
|
|
|
`AI Generation failed on all available providers. Details: ${warnings.join(" | ")}`
|
|
|
|
|
);
|
|
|
|
|
}
|