feat(02-01): implement config loader and Gemini API client

- Config loader: fetches/caches public/config.json with GeminiConfig interface
- generateText: Gemini REST with 3x exponential backoff retry, returns string|null
- generateImage: reference images as base64 inlineData, responseModalities: ["IMAGE"]
- Rate limiting: checkRateLimit/incrementApiCall with daily reset in Settings store
- Extended Settings type with apiCallsToday and lastApiCallDate fields
- 18 tests across config.test.ts and gemini.test.ts, all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 11:56:21 +02:00
co-authored by Claude Opus 4.6
parent 197ec24aa2
commit 2bea324962
5 changed files with 220 additions and 3 deletions
+31
View File
@@ -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<GeminiConfig | null> {
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;
}
+4 -3
View File
@@ -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"]);
});
+181
View File
@@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function generateText(
prompt: string,
systemPrompt: string,
config: GeminiConfig,
): Promise<string | null> {
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<string> {
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<Blob | null> {
const url = `${GEMINI_BASE_URL}/${config.imageModel}:generateContent?key=${config.geminiApiKey}`;
try {
const parts: Array<Record<string, unknown>> = [];
// 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<Settings> {
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<boolean> {
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<void> {
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);
}
+2
View File
@@ -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);
+2
View File
@@ -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)