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:
@@ -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;
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user