feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates
This commit is contained in:
commit
e04672db2a
104 files changed
+8262
No files matched your search
@@ -0,0 +1,18 @@
|
||||
// Provider cooldown is completely disabled: transient failures or safety rejections
|
||||
// will never lock out the API for subsequent requests.
|
||||
export function isProviderAvailable(_providerName: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function triggerCooldown(
|
||||
_providerName: string,
|
||||
_durationMinutes: number,
|
||||
_reason: string
|
||||
): void {
|
||||
// No-op: Cooldown disabled
|
||||
}
|
||||
|
||||
export function resetProvider(_providerName: string): void {
|
||||
// No-op
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ContentSafetyError } from "./safety";
|
||||
export interface GeminiGenerateOptions {
|
||||
model: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
timeoutMs: number;
|
||||
enableSfwCheck: boolean;
|
||||
}
|
||||
|
||||
export interface GeminiResult {
|
||||
imageUrl: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function generateWithGemini(
|
||||
options: GeminiGenerateOptions
|
||||
): Promise<GeminiResult> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("GEMINI_API_KEY is not configured in environment variables");
|
||||
}
|
||||
|
||||
// Clean base64 string
|
||||
const cleanBase64 = options.imageBase64.replace(/^data:image\/\w+;base64,/, "");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${options.model}:generateContent?key=${apiKey}`;
|
||||
|
||||
const requestBody: any = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
text: `${options.prompt}\n\nNegative directives: ${options.negativePrompt || "none"}`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: options.mimeType || "image/jpeg",
|
||||
data: cleanBase64,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (options.enableSfwCheck) {
|
||||
requestBody.safetySettings = [
|
||||
{
|
||||
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_HATE_SPEECH",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_HARASSMENT",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
{
|
||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
threshold: "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (
|
||||
errorText.includes("PROHIBITED_CONTENT") ||
|
||||
errorText.includes("HARM_CATEGORY") ||
|
||||
errorText.includes("SAFETY")
|
||||
) {
|
||||
throw new ContentSafetyError(
|
||||
`Image rejected by content safety filter: PROHIBITED_CONTENT`
|
||||
);
|
||||
}
|
||||
throw new Error(`Gemini API error [${response.status}]: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check safety feedback
|
||||
if (data.promptFeedback?.blockReason) {
|
||||
throw new ContentSafetyError(
|
||||
`Image rejected by content safety filter: ${data.promptFeedback.blockReason}`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract generated image from candidates
|
||||
const candidate = data.candidates?.[0];
|
||||
if (!candidate) {
|
||||
throw new Error("Gemini returned no candidates in response");
|
||||
}
|
||||
|
||||
// Check candidate finishReason for safety blocks
|
||||
if (candidate.finishReason === "SAFETY" || candidate.finishReason === "BLOCKLIST") {
|
||||
throw new ContentSafetyError(
|
||||
"Image rejected by content safety filter: output triggered Safe-for-Work (SFW) protection."
|
||||
);
|
||||
}
|
||||
|
||||
// Look for inline image in response parts
|
||||
const parts = candidate.content?.parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.inlineData?.data) {
|
||||
const mime = part.inlineData.mimeType || "image/png";
|
||||
return {
|
||||
imageUrl: `data:${mime};base64,${part.inlineData.data}`,
|
||||
provider: "gemini",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If text returned or URL in text
|
||||
const textPart = parts.find((p: any) => p.text)?.text || "";
|
||||
const urlMatch = textPart.match(/https?:\/\/[^\s"']+\.(?:png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch) {
|
||||
return {
|
||||
imageUrl: urlMatch[0],
|
||||
provider: "gemini",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Gemini did not return an image part in the response payload. Please verify model image generation capabilities."
|
||||
);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error(`Gemini request timed out after ${options.timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export interface OpenRouterGenerateOptions {
|
||||
model: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface OpenRouterResult {
|
||||
imageUrl: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export async function generateWithOpenRouter(
|
||||
options: OpenRouterGenerateOptions
|
||||
): Promise<OpenRouterResult> {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENROUTER_API_KEY is not configured in environment variables");
|
||||
}
|
||||
|
||||
const cleanBase64 = options.imageBase64.startsWith("data:")
|
||||
? options.imageBase64
|
||||
: `data:${options.mimeType || "image/jpeg"};base64,${options.imageBase64}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://photobooth.local",
|
||||
"X-Title": "AI PhotoBooth",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${options.prompt}\n\nNegative prompt: ${options.negativePrompt || "none"}`,
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: cleanBase64,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenRouter API error [${response.status}]: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const choice = data.choices?.[0];
|
||||
const message = choice?.message;
|
||||
|
||||
// 1. Check OpenRouter image output attachments (message.images: [{ image_url: { url: ... } }])
|
||||
if (Array.isArray(message?.images) && message.images.length > 0) {
|
||||
for (const img of message.images) {
|
||||
const url = img?.image_url?.url || img?.url;
|
||||
if (url) {
|
||||
return {
|
||||
imageUrl: url,
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error("OpenRouter returned an empty completion");
|
||||
}
|
||||
|
||||
// Check if content is or contains an image URL or base64 data
|
||||
const base64Match = content.match(/data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+/);
|
||||
if (base64Match) {
|
||||
return {
|
||||
imageUrl: base64Match[0],
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
const urlMatch = content.match(/https?:\/\/[^\s"']+\.(?:png|jpg|jpeg|webp)/i);
|
||||
if (urlMatch) {
|
||||
return {
|
||||
imageUrl: urlMatch[0],
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
// Some models return markdown image: 
|
||||
const mdMatch = content.match(/!\[.*?\]\((https?:\/\/.*?)\)/);
|
||||
if (mdMatch) {
|
||||
return {
|
||||
imageUrl: mdMatch[1],
|
||||
provider: "openrouter",
|
||||
model: options.model,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`OpenRouter response did not contain an image. Output received: ${content.slice(0, 120)}...`
|
||||
);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error(`OpenRouter request timed out after ${options.timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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."],
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Try Primary Provider (Google Gemini)
|
||||
if (isProviderAvailable("gemini") && hasGeminiKey) {
|
||||
try {
|
||||
console.log(`[AI Orchestrator] Attempting Primary (Gemini: ${config.ai.primary.model})...`);
|
||||
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: false,
|
||||
};
|
||||
} catch (primaryError: any) {
|
||||
// Content safety rejections are caused by user input, NOT provider downtime
|
||||
if (isContentSafetyError(primaryError)) {
|
||||
console.warn(`[AI Orchestrator] Primary provider (Gemini) rejected image for safety:`, primaryError.message);
|
||||
throw new ContentSafetyError(primaryError.message);
|
||||
}
|
||||
|
||||
console.warn(`[AI Orchestrator] Primary provider (Gemini) failed:`, primaryError.message);
|
||||
warnings.push(`Primary failed: ${primaryError.message}`);
|
||||
}
|
||||
} else {
|
||||
warnings.push("Primary provider key missing. Skipping to backup.");
|
||||
}
|
||||
|
||||
// 2. Try Backup Provider (OpenRouter)
|
||||
if (isProviderAvailable("openrouter") && hasOpenRouterKey) {
|
||||
try {
|
||||
console.log(`[AI Orchestrator] Attempting Backup (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,
|
||||
});
|
||||
|
||||
return {
|
||||
imageUrl: result.imageUrl,
|
||||
providerUsed: "openrouter",
|
||||
modelUsed: result.model,
|
||||
durationMs: Date.now() - startTime,
|
||||
fallbackTriggered: true,
|
||||
warnings,
|
||||
};
|
||||
} catch (backupError: any) {
|
||||
if (isContentSafetyError(backupError)) {
|
||||
console.warn(`[AI Orchestrator] Backup provider (OpenRouter) rejected image for safety:`, backupError.message);
|
||||
throw new ContentSafetyError(backupError.message);
|
||||
}
|
||||
|
||||
console.error(`[AI Orchestrator] Backup provider (OpenRouter) failed:`, backupError.message);
|
||||
warnings.push(`Backup failed: ${backupError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If both providers failed or were unavailable, throw descriptive error
|
||||
throw new Error(
|
||||
`AI Generation failed on all available providers. Details: ${warnings.join(" | ")}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export class ContentSafetyError extends Error {
|
||||
public readonly isSafetyRejection = true;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ContentSafetyError";
|
||||
Object.setPrototypeOf(this, ContentSafetyError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export function isContentSafetyError(err: any): boolean {
|
||||
if (!err) return false;
|
||||
if (err.isSafetyRejection || err.name === "ContentSafetyError") return true;
|
||||
|
||||
const msg = String(err.message || err.error || err || "").toLowerCase();
|
||||
return (
|
||||
msg.includes("safety filter") ||
|
||||
msg.includes("prohibited_content") ||
|
||||
msg.includes("harm_category") ||
|
||||
msg.includes("safe-for-work") ||
|
||||
msg.includes("finishreason: safety") ||
|
||||
msg.includes("finishreason: blocklist") ||
|
||||
msg.includes("blockreason") ||
|
||||
msg.includes("content safety") ||
|
||||
msg.includes("safety policy") ||
|
||||
msg.includes("violates safety")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user