diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..1a73330 --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,31 @@ +export interface GeminiConfig { + geminiApiKey: string; + geminiModel: string; // e.g. "gemini-2.5-flash" + imageModel: string; // e.g. "gemini-3.1-flash-preview-image" +} + +let cachedConfig: GeminiConfig | null = null; + +export async function loadConfig(): Promise { + if (cachedConfig) return cachedConfig; + try { + const res = await fetch("/config.json"); + if (!res.ok) return null; + const data = await res.json(); + if (!data.geminiApiKey || !data.geminiModel || !data.imageModel) + return null; + cachedConfig = data as GeminiConfig; + return cachedConfig; + } catch { + return null; + } +} + +/** For Node.js usage in build script */ +export function loadConfigFromObject(obj: GeminiConfig): void { + cachedConfig = obj; +} + +export function resetConfigCache(): void { + cachedConfig = null; +} diff --git a/src/api/gemini.test.ts b/src/api/gemini.test.ts index 48c85ae..0935e7d 100644 --- a/src/api/gemini.test.ts +++ b/src/api/gemini.test.ts @@ -85,7 +85,8 @@ describe("generateText", () => { await generateText("my prompt", "my system prompt", TEST_CONFIG); expect(fetchSpy).toHaveBeenCalledTimes(1); - const [url, options] = fetchSpy.mock.calls[0]; + const call = fetchSpy.mock.calls[0]!; + const [url, options] = call; expect(url).toContain("gemini-2.5-flash"); expect(url).toContain("generateContent"); expect(url).toContain("key=test-api-key"); @@ -173,7 +174,7 @@ describe("generateImage", () => { const refBlob = new Blob(["fake-image-data"], { type: "image/png" }); await generateImage("a forest", [refBlob], TEST_CONFIG); - const [url, options] = fetchSpy.mock.calls[0]; + const [url, options] = fetchSpy.mock.calls[0]!; expect(url).toContain("gemini-3.1-flash-preview-image"); const body = JSON.parse(options?.body as string); // First part should be inlineData (reference image), last part should be text prompt @@ -195,7 +196,7 @@ describe("generateImage", () => { await generateImage("a tree", [], TEST_CONFIG); - const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + const body = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string); expect(body.generationConfig.responseModalities).toEqual(["IMAGE"]); }); diff --git a/src/api/gemini.ts b/src/api/gemini.ts new file mode 100644 index 0000000..d8a05ba --- /dev/null +++ b/src/api/gemini.ts @@ -0,0 +1,181 @@ +import { getSettings, saveSettings } from "../storage/db"; +import type { Settings } from "../types"; +import type { GeminiConfig } from "./config"; + +const GEMINI_BASE_URL = + "https://generativelanguage.googleapis.com/v1beta/models"; +const RETRY_DELAYS = [1000, 2000, 4000]; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function generateText( + prompt: string, + systemPrompt: string, + config: GeminiConfig, +): Promise { + const url = `${GEMINI_BASE_URL}/${config.geminiModel}:generateContent?key=${config.geminiApiKey}`; + const body = { + contents: [{ role: "user", parts: [{ text: prompt }] }], + systemInstruction: { parts: [{ text: systemPrompt }] }, + }; + + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + if (attempt < 2) { + await sleep(RETRY_DELAYS[attempt] ?? 1000); + continue; + } + return null; + } + + const data = await res.json(); + return data.candidates[0].content.parts[0].text; + } catch { + if (attempt < 2) { + await sleep(RETRY_DELAYS[attempt] ?? 1000); + continue; + } + return null; + } + } + + return null; +} + +async function blobToBase64(blob: Blob): Promise { + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function base64ToBlob(base64: string, mimeType: string): Blob { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: mimeType }); +} + +export async function generateImage( + prompt: string, + referenceImages: Blob[], + config: GeminiConfig, +): Promise { + const url = `${GEMINI_BASE_URL}/${config.imageModel}:generateContent?key=${config.geminiApiKey}`; + + try { + const parts: Array> = []; + + // Add reference images as inlineData parts + for (const refImage of referenceImages) { + const base64 = await blobToBase64(refImage); + parts.push({ + inlineData: { + mimeType: refImage.type || "image/png", + data: base64, + }, + }); + } + + // Add text prompt as last part + parts.push({ text: prompt }); + + const body = { + contents: [{ role: "user", parts }], + generationConfig: { responseModalities: ["IMAGE"] }, + }; + + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) return null; + + const data = await res.json(); + const inlineData = data.candidates[0].content.parts[0].inlineData; + return base64ToBlob(inlineData.data, inlineData.mimeType || "image/png"); + } catch { + return null; + } +} + +function getTodayDate(): string { + return new Date().toISOString().split("T")[0] ?? ""; +} + +function getDefaultSettings(): Settings { + return { + id: 1, + audioEnabled: true, + apiKey: "", + apiCallsToday: { text: 0, image: 0 }, + lastApiCallDate: getTodayDate(), + }; +} + +async function getOrCreateSettings(db: IDBDatabase): Promise { + const settings = await getSettings(db); + if (!settings) { + const defaults = getDefaultSettings(); + await saveSettings(db, defaults); + return defaults; + } + // Migrate old settings that don't have rate-limit fields + if (!settings.apiCallsToday) { + settings.apiCallsToday = { text: 0, image: 0 }; + settings.lastApiCallDate = getTodayDate(); + await saveSettings(db, settings); + } + return settings; +} + +export async function checkRateLimit( + db: IDBDatabase, + type: "text" | "image", +): Promise { + const settings = await getOrCreateSettings(db); + const today = getTodayDate(); + + // Reset if date changed + if (settings.lastApiCallDate !== today) { + settings.apiCallsToday = { text: 0, image: 0 }; + settings.lastApiCallDate = today; + await saveSettings(db, settings); + } + + return settings.apiCallsToday[type] < 1; +} + +export async function incrementApiCall( + db: IDBDatabase, + type: "text" | "image", +): Promise { + const settings = await getOrCreateSettings(db); + const today = getTodayDate(); + + // Reset if date changed + if (settings.lastApiCallDate !== today) { + settings.apiCallsToday = { text: 0, image: 0 }; + settings.lastApiCallDate = today; + } + + settings.apiCallsToday[type]++; + settings.lastApiCallDate = today; + await saveSettings(db, settings); +} diff --git a/src/storage/db.test.ts b/src/storage/db.test.ts index d879137..5c8106e 100644 --- a/src/storage/db.test.ts +++ b/src/storage/db.test.ts @@ -44,6 +44,8 @@ describe("IndexedDB wrapper", () => { id: 1, audioEnabled: true, apiKey: "test-key", + apiCallsToday: { text: 0, image: 0 }, + lastApiCallDate: "2026-03-29", }; await saveSettings(db, settings); const result = await getSettings(db); diff --git a/src/types.ts b/src/types.ts index 5bc6b4f..f3c7d84 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,6 +48,8 @@ export interface Settings { id: 1; // singleton audioEnabled: boolean; apiKey: string; + apiCallsToday: { text: number; image: number }; + lastApiCallDate: string; // ISO date "YYYY-MM-DD" } // Level definition (used in game/levels.ts)