refactor: remove checkRateLimit and incrementApiCall completely
This commit is contained in:
@@ -27,6 +27,8 @@ Technology stack not yet documented. Will populate after codebase mapping or fir
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Immer deutsche Umlaute (ä, ö, ü, Ä, Ö, Ü, ß) in UI-Texten verwenden, niemals ae/oe/ue Transliterierung.
|
- Immer deutsche Umlaute (ä, ö, ü, Ä, Ö, Ü, ß) in UI-Texten verwenden, niemals ae/oe/ue Transliterierung.
|
||||||
|
- Kein No-Op Code: Keine leeren Funktionen, keine Stubs, keine auskommentierten Blöcke. Wenn etwas entfernt wird, komplett entfernen — inklusive Aufrufe, Imports, Typen und Tests.
|
||||||
|
- Never predict the future: Kein Code "für später", keine Abstraktionen für hypothetische Anforderungen, keine Platzhalter-Interfaces. Nur implementieren was jetzt gebraucht wird.
|
||||||
<!-- GSD:conventions-end -->
|
<!-- GSD:conventions-end -->
|
||||||
|
|
||||||
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
||||||
|
|||||||
+1
-16
@@ -1,11 +1,6 @@
|
|||||||
import "fake-indexeddb/auto";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { GeminiConfig } from "./config";
|
import type { GeminiConfig } from "./config";
|
||||||
import {
|
import { generateImage, generateText } from "./gemini";
|
||||||
checkRateLimit,
|
|
||||||
generateImage,
|
|
||||||
generateText,
|
|
||||||
} from "./gemini";
|
|
||||||
|
|
||||||
const TEST_CONFIG: GeminiConfig = {
|
const TEST_CONFIG: GeminiConfig = {
|
||||||
geminiApiKey: "test-api-key",
|
geminiApiKey: "test-api-key",
|
||||||
@@ -13,8 +8,6 @@ const TEST_CONFIG: GeminiConfig = {
|
|||||||
imageModel: "gemini-3.1-flash-preview-image",
|
imageModel: "gemini-3.1-flash-preview-image",
|
||||||
};
|
};
|
||||||
|
|
||||||
let testCounter = 0;
|
|
||||||
|
|
||||||
function makeGeminiTextResponse(text: string) {
|
function makeGeminiTextResponse(text: string) {
|
||||||
return {
|
return {
|
||||||
candidates: [
|
candidates: [
|
||||||
@@ -207,11 +200,3 @@ describe("generateImage", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Rate limiting (disabled)", () => {
|
|
||||||
it("checkRateLimit always returns true (no limits)", async () => {
|
|
||||||
const db = {} as IDBDatabase;
|
|
||||||
expect(await checkRateLimit(db, "text")).toBe(true);
|
|
||||||
expect(await checkRateLimit(db, "image")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -112,18 +112,3 @@ export async function generateImage(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function checkRateLimit(
|
|
||||||
_db: IDBDatabase,
|
|
||||||
_type: "text" | "image",
|
|
||||||
): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function incrementApiCall(
|
|
||||||
_db: IDBDatabase,
|
|
||||||
_type: "text" | "image",
|
|
||||||
): Promise<void> {
|
|
||||||
// No-op: rate limits removed
|
|
||||||
}
|
|
||||||
|
|||||||
+18
-8
@@ -8,6 +8,7 @@ import { getForestElements, getProgress, openDB, saveProgress } from "./storage/
|
|||||||
import type { ScreenName } from "./types";
|
import type { ScreenName } from "./types";
|
||||||
import { initParentScreen, showParentCodePrompt } from "./ui/parent";
|
import { initParentScreen, showParentCodePrompt } from "./ui/parent";
|
||||||
import { initLessonScreen, initWelcomeScreen } from "./ui/screens";
|
import { initLessonScreen, initWelcomeScreen } from "./ui/screens";
|
||||||
|
import { levels } from "./game/levels";
|
||||||
|
|
||||||
let db: IDBDatabase;
|
let db: IDBDatabase;
|
||||||
let lessonCleanup: (() => void) | null = null;
|
let lessonCleanup: (() => void) | null = null;
|
||||||
@@ -26,8 +27,6 @@ export function getDB(): IDBDatabase {
|
|||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_LEVEL = 6;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure progress object has all fields (migration for older saved data).
|
* Ensure progress object has all fields (migration for older saved data).
|
||||||
*/
|
*/
|
||||||
@@ -39,6 +38,16 @@ function migrateProgress(
|
|||||||
if (progress.totalCorrect === undefined) progress.totalCorrect = 0;
|
if (progress.totalCorrect === undefined) progress.totalCorrect = 0;
|
||||||
if (progress.totalErrors === undefined) progress.totalErrors = 0;
|
if (progress.totalErrors === undefined) progress.totalErrors = 0;
|
||||||
if (progress.currentForestPage === undefined) progress.currentForestPage = 0;
|
if (progress.currentForestPage === undefined) progress.currentForestPage = 0;
|
||||||
|
|
||||||
|
// Migration: advance currentLevel if it was previously capped at a completed level.
|
||||||
|
// This fixes saved data from before levels 7-16 existed, where MAX_LEVEL was 6.
|
||||||
|
const maxLevel = levels[levels.length - 1]?.level ?? 16;
|
||||||
|
if (
|
||||||
|
progress.completedLevels.includes(progress.currentLevel) &&
|
||||||
|
progress.currentLevel < maxLevel
|
||||||
|
) {
|
||||||
|
progress.currentLevel = progress.currentLevel + 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLessonComplete(
|
async function handleLessonComplete(
|
||||||
@@ -74,10 +83,11 @@ async function handleLessonComplete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Advance to next level if this was the current level (LEVL-02, LEVL-03)
|
// Advance to next level if this was the current level (LEVL-02, LEVL-03)
|
||||||
// Cap at MAX_LEVEL so we don't go beyond defined levels
|
// Cap at the total number of defined levels so we don't go beyond
|
||||||
|
const maxLevel = levels[levels.length - 1]?.level ?? 16;
|
||||||
if (
|
if (
|
||||||
completedLevel === progress.currentLevel &&
|
completedLevel === progress.currentLevel &&
|
||||||
completedLevel < MAX_LEVEL
|
completedLevel < maxLevel
|
||||||
) {
|
) {
|
||||||
progress.currentLevel = completedLevel + 1;
|
progress.currentLevel = completedLevel + 1;
|
||||||
}
|
}
|
||||||
@@ -114,9 +124,7 @@ async function handleLessonComplete(
|
|||||||
progress.selectedCharacter,
|
progress.selectedCharacter,
|
||||||
() => {
|
() => {
|
||||||
// "Weiter üben" -- start next lesson
|
// "Weiter üben" -- start next lesson
|
||||||
startLessonFromForest(
|
startLessonFromForest(progress.currentLevel);
|
||||||
Math.min(progress.currentLevel, MAX_LEVEL),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
// "Zurück zum Wald" -- navigate to page containing the new element
|
// "Zurück zum Wald" -- navigate to page containing the new element
|
||||||
@@ -214,6 +222,8 @@ export async function initApp(): Promise<void> {
|
|||||||
const progress = await getProgress(db);
|
const progress = await getProgress(db);
|
||||||
if (progress && progress.selectedCharacter) {
|
if (progress && progress.selectedCharacter) {
|
||||||
migrateProgress(progress);
|
migrateProgress(progress);
|
||||||
|
// Persist any migrations (e.g. currentLevel advancement) before rendering
|
||||||
|
await saveProgress(db, progress);
|
||||||
|
|
||||||
// Returning user -- init forest and go there
|
// Returning user -- init forest and go there
|
||||||
await initForestScreen(db, startLessonFromForest);
|
await initForestScreen(db, startLessonFromForest);
|
||||||
@@ -235,7 +245,7 @@ export async function initApp(): Promise<void> {
|
|||||||
greetingEl.style.display = "flex";
|
greetingEl.style.display = "flex";
|
||||||
|
|
||||||
// Fetch greeting async (shows immediately with avatar, text fills in)
|
// Fetch greeting async (shows immediately with avatar, text fills in)
|
||||||
getGreeting(progress.selectedCharacter, db).then((text) => {
|
getGreeting(progress.selectedCharacter).then((text) => {
|
||||||
textEl.innerHTML = renderInlineMarkdown(text);
|
textEl.innerHTML = renderInlineMarkdown(text);
|
||||||
speakText(text);
|
speakText(text);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import "fake-indexeddb/auto";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { openDB } from "../storage/db";
|
|
||||||
import type { GeminiConfig } from "../api/config";
|
import type { GeminiConfig } from "../api/config";
|
||||||
|
|
||||||
// Mock modules before importing the module under test
|
// Mock modules before importing the module under test
|
||||||
@@ -11,16 +9,10 @@ vi.mock("../api/config", () => ({
|
|||||||
|
|
||||||
vi.mock("../api/gemini", () => ({
|
vi.mock("../api/gemini", () => ({
|
||||||
generateText: vi.fn(),
|
generateText: vi.fn(),
|
||||||
checkRateLimit: vi.fn(),
|
|
||||||
incrementApiCall: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { loadConfig } from "../api/config";
|
import { loadConfig } from "../api/config";
|
||||||
import {
|
import { generateText } from "../api/gemini";
|
||||||
checkRateLimit,
|
|
||||||
generateText,
|
|
||||||
incrementApiCall,
|
|
||||||
} from "../api/gemini";
|
|
||||||
import { getGreeting, getLetterIntro, getForestComment } from "./companion";
|
import { getGreeting, getLetterIntro, getForestComment } from "./companion";
|
||||||
|
|
||||||
const TEST_CONFIG: GeminiConfig = {
|
const TEST_CONFIG: GeminiConfig = {
|
||||||
@@ -29,13 +21,8 @@ const TEST_CONFIG: GeminiConfig = {
|
|||||||
imageModel: "gemini-3.1-flash-preview-image",
|
imageModel: "gemini-3.1-flash-preview-image",
|
||||||
};
|
};
|
||||||
|
|
||||||
let testCounter = 0;
|
beforeEach(() => {
|
||||||
let db: IDBDatabase;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
testCounter++;
|
|
||||||
db = await openDB(`companion-test-${testCounter}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -43,24 +30,22 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getGreeting", () => {
|
describe("getGreeting", () => {
|
||||||
it("returns AI text when API succeeds and rate limit not exceeded", async () => {
|
it("returns AI text when API succeeds", async () => {
|
||||||
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
||||||
vi.mocked(checkRateLimit).mockResolvedValue(true);
|
|
||||||
vi.mocked(generateText).mockResolvedValue(
|
vi.mocked(generateText).mockResolvedValue(
|
||||||
"Guten Morgen, kleiner Waldfreund!",
|
"Guten Morgen, kleiner Waldfreund!",
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await getGreeting("fee", db);
|
const result = await getGreeting("fee");
|
||||||
|
|
||||||
expect(result).toBe("Guten Morgen, kleiner Waldfreund!");
|
expect(result).toBe("Guten Morgen, kleiner Waldfreund!");
|
||||||
expect(generateText).toHaveBeenCalledTimes(1);
|
expect(generateText).toHaveBeenCalledTimes(1);
|
||||||
expect(incrementApiCall).toHaveBeenCalledWith(db, "text");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns fallback text when loadConfig returns null (no API key)", async () => {
|
it("returns fallback text when loadConfig returns null (no API key)", async () => {
|
||||||
vi.mocked(loadConfig).mockResolvedValue(null);
|
vi.mocked(loadConfig).mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await getGreeting("fee", db);
|
const result = await getGreeting("fee");
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
expect(result).toBeTruthy();
|
||||||
expect(typeof result).toBe("string");
|
expect(typeof result).toBe("string");
|
||||||
@@ -69,32 +54,19 @@ describe("getGreeting", () => {
|
|||||||
|
|
||||||
it("returns fallback text when generateText returns null (API error)", async () => {
|
it("returns fallback text when generateText returns null (API error)", async () => {
|
||||||
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
||||||
vi.mocked(checkRateLimit).mockResolvedValue(true);
|
|
||||||
vi.mocked(generateText).mockResolvedValue(null);
|
vi.mocked(generateText).mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await getGreeting("fee", db);
|
const result = await getGreeting("fee");
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
expect(result).toBeTruthy();
|
||||||
expect(typeof result).toBe("string");
|
expect(typeof result).toBe("string");
|
||||||
expect(incrementApiCall).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns fallback text when rate limit exceeded", async () => {
|
|
||||||
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
|
||||||
vi.mocked(checkRateLimit).mockResolvedValue(false);
|
|
||||||
|
|
||||||
const result = await getGreeting("fee", db);
|
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(generateText).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes correct system prompt with companion personality and time of day", async () => {
|
it("passes correct system prompt with companion personality and time of day", async () => {
|
||||||
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
||||||
vi.mocked(checkRateLimit).mockResolvedValue(true);
|
|
||||||
vi.mocked(generateText).mockResolvedValue("Hallo!");
|
vi.mocked(generateText).mockResolvedValue("Hallo!");
|
||||||
|
|
||||||
await getGreeting("fee", db);
|
await getGreeting("fee");
|
||||||
|
|
||||||
const systemPrompt = vi.mocked(generateText).mock.calls[0]![1];
|
const systemPrompt = vi.mocked(generateText).mock.calls[0]![1];
|
||||||
expect(systemPrompt).toContain("Lila");
|
expect(systemPrompt).toContain("Lila");
|
||||||
@@ -105,21 +77,11 @@ describe("getGreeting", () => {
|
|||||||
expect(systemPrompt).toContain("Max. 2 kurze Sätze");
|
expect(systemPrompt).toContain("Max. 2 kurze Sätze");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls incrementApiCall after successful API call", async () => {
|
|
||||||
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
|
||||||
vi.mocked(checkRateLimit).mockResolvedValue(true);
|
|
||||||
vi.mocked(generateText).mockResolvedValue("Hallo Waldkind!");
|
|
||||||
|
|
||||||
await getGreeting("einhorn", db);
|
|
||||||
|
|
||||||
expect(incrementApiCall).toHaveBeenCalledWith(db, "text");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getLetterIntro", () => {
|
describe("getLetterIntro", () => {
|
||||||
it("returns AI text when API succeeds", async () => {
|
it("returns AI text when API succeeds", async () => {
|
||||||
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
||||||
vi.mocked(checkRateLimit).mockResolvedValue(true);
|
|
||||||
vi.mocked(generateText).mockResolvedValue(
|
vi.mocked(generateText).mockResolvedValue(
|
||||||
"Das F ist wie ein Farn im Wald!",
|
"Das F ist wie ein Farn im Wald!",
|
||||||
);
|
);
|
||||||
@@ -128,11 +90,9 @@ describe("getLetterIntro", () => {
|
|||||||
"fee",
|
"fee",
|
||||||
"f",
|
"f",
|
||||||
"linker Zeigefinger",
|
"linker Zeigefinger",
|
||||||
db,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe("Das F ist wie ein Farn im Wald!");
|
expect(result).toBe("Das F ist wie ein Farn im Wald!");
|
||||||
expect(incrementApiCall).toHaveBeenCalledWith(db, "text");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns fallback LetterIntro text when API unavailable", async () => {
|
it("returns fallback LetterIntro text when API unavailable", async () => {
|
||||||
@@ -142,7 +102,6 @@ describe("getLetterIntro", () => {
|
|||||||
"fee",
|
"fee",
|
||||||
"f",
|
"f",
|
||||||
"linker Zeigefinger",
|
"linker Zeigefinger",
|
||||||
db,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
expect(result).toBeTruthy();
|
||||||
@@ -152,10 +111,9 @@ describe("getLetterIntro", () => {
|
|||||||
|
|
||||||
it("system prompt includes letter, finger description, and companion personality", async () => {
|
it("system prompt includes letter, finger description, and companion personality", async () => {
|
||||||
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
||||||
vi.mocked(checkRateLimit).mockResolvedValue(true);
|
|
||||||
vi.mocked(generateText).mockResolvedValue("Das D wohnt hier!");
|
vi.mocked(generateText).mockResolvedValue("Das D wohnt hier!");
|
||||||
|
|
||||||
await getLetterIntro("fuchs", "d", "linker Mittelfinger", db);
|
await getLetterIntro("fuchs", "d", "linker Mittelfinger");
|
||||||
|
|
||||||
const systemPrompt = vi.mocked(generateText).mock.calls[0]![1];
|
const systemPrompt = vi.mocked(generateText).mock.calls[0]![1];
|
||||||
expect(systemPrompt).toContain("Finn");
|
expect(systemPrompt).toContain("Finn");
|
||||||
@@ -171,7 +129,6 @@ describe("getLetterIntro", () => {
|
|||||||
"fee",
|
"fee",
|
||||||
"@",
|
"@",
|
||||||
"linker kleiner Finger",
|
"linker kleiner Finger",
|
||||||
db,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe("Drücke die leuchtende Taste!");
|
expect(result).toBe("Drücke die leuchtende Taste!");
|
||||||
@@ -181,7 +138,6 @@ describe("getLetterIntro", () => {
|
|||||||
describe("getForestComment", () => {
|
describe("getForestComment", () => {
|
||||||
it("returns AI text when API succeeds", async () => {
|
it("returns AI text when API succeeds", async () => {
|
||||||
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
||||||
vi.mocked(checkRateLimit).mockResolvedValue(true);
|
|
||||||
vi.mocked(generateText).mockResolvedValue(
|
vi.mocked(generateText).mockResolvedValue(
|
||||||
"Oh, ein Schmetterling ist erschienen!",
|
"Oh, ein Schmetterling ist erschienen!",
|
||||||
);
|
);
|
||||||
@@ -189,11 +145,9 @@ describe("getForestComment", () => {
|
|||||||
const result = await getForestComment(
|
const result = await getForestComment(
|
||||||
"eule",
|
"eule",
|
||||||
"ein blauer Schmetterling",
|
"ein blauer Schmetterling",
|
||||||
db,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe("Oh, ein Schmetterling ist erschienen!");
|
expect(result).toBe("Oh, ein Schmetterling ist erschienen!");
|
||||||
expect(incrementApiCall).toHaveBeenCalledWith(db, "text");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns fallback text when API unavailable", async () => {
|
it("returns fallback text when API unavailable", async () => {
|
||||||
@@ -202,7 +156,6 @@ describe("getForestComment", () => {
|
|||||||
const result = await getForestComment(
|
const result = await getForestComment(
|
||||||
"eule",
|
"eule",
|
||||||
"ein blauer Schmetterling",
|
"ein blauer Schmetterling",
|
||||||
db,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
expect(result).toBeTruthy();
|
||||||
@@ -212,10 +165,9 @@ describe("getForestComment", () => {
|
|||||||
|
|
||||||
it("system prompt includes element description and companion personality", async () => {
|
it("system prompt includes element description and companion personality", async () => {
|
||||||
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG);
|
||||||
vi.mocked(checkRateLimit).mockResolvedValue(true);
|
|
||||||
vi.mocked(generateText).mockResolvedValue("Wie wunderschön!");
|
vi.mocked(generateText).mockResolvedValue("Wie wunderschön!");
|
||||||
|
|
||||||
await getForestComment("einhorn", "ein leuchtender Pilz", db);
|
await getForestComment("einhorn", "ein leuchtender Pilz");
|
||||||
|
|
||||||
const systemPrompt = vi.mocked(generateText).mock.calls[0]![1];
|
const systemPrompt = vi.mocked(generateText).mock.calls[0]![1];
|
||||||
expect(systemPrompt).toContain("Stella");
|
expect(systemPrompt).toContain("Stella");
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
* Companion text module: AI-generated or fallback text for greetings,
|
* Companion text module: AI-generated or fallback text for greetings,
|
||||||
* letter introductions, and forest comments.
|
* letter introductions, and forest comments.
|
||||||
*
|
*
|
||||||
* Strategy: Try Gemini API first (with rate-limit check), fall back
|
* Strategy: Try Gemini API first, fall back to curated static texts
|
||||||
* to curated static texts when API is unavailable.
|
* when API is unavailable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { companions } from "./characters";
|
import { companions } from "./characters";
|
||||||
import type { CompanionType } from "../types";
|
import type { CompanionType } from "../types";
|
||||||
import { loadConfig } from "../api/config";
|
import { loadConfig } from "../api/config";
|
||||||
import { generateText, checkRateLimit, incrementApiCall } from "../api/gemini";
|
import { generateText } from "../api/gemini";
|
||||||
import {
|
import {
|
||||||
type TimeOfDay,
|
type TimeOfDay,
|
||||||
getRandomFallbackGreeting,
|
getRandomFallbackGreeting,
|
||||||
@@ -30,15 +30,11 @@ function getCompanion(type: CompanionType) {
|
|||||||
|
|
||||||
export async function getGreeting(
|
export async function getGreeting(
|
||||||
characterType: CompanionType,
|
characterType: CompanionType,
|
||||||
db: IDBDatabase,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const timeOfDay = getTimeOfDay();
|
const timeOfDay = getTimeOfDay();
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
if (!config) return getRandomFallbackGreeting(timeOfDay);
|
if (!config) return getRandomFallbackGreeting(timeOfDay);
|
||||||
|
|
||||||
const canCall = await checkRateLimit(db, "text");
|
|
||||||
if (!canCall) return getRandomFallbackGreeting(timeOfDay);
|
|
||||||
|
|
||||||
const companion = getCompanion(characterType);
|
const companion = getCompanion(characterType);
|
||||||
const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald.
|
const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald.
|
||||||
Du sprichst mit einem 7-jährigen Kind, das Tippen lernt.
|
Du sprichst mit einem 7-jährigen Kind, das Tippen lernt.
|
||||||
@@ -56,17 +52,13 @@ Regeln:
|
|||||||
systemPrompt,
|
systemPrompt,
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
if (!result) return getRandomFallbackGreeting(timeOfDay);
|
return result ?? getRandomFallbackGreeting(timeOfDay);
|
||||||
|
|
||||||
await incrementApiCall(db, "text");
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLetterIntro(
|
export async function getLetterIntro(
|
||||||
characterType: CompanionType,
|
characterType: CompanionType,
|
||||||
letter: string,
|
letter: string,
|
||||||
fingerDescription: string,
|
fingerDescription: string,
|
||||||
db: IDBDatabase,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -76,14 +68,6 @@ export async function getLetterIntro(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canCall = await checkRateLimit(db, "text");
|
|
||||||
if (!canCall) {
|
|
||||||
return (
|
|
||||||
getFallbackLetterIntro(letter)?.text ??
|
|
||||||
"Drücke die leuchtende Taste!"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const companion = getCompanion(characterType);
|
const companion = getCompanion(characterType);
|
||||||
const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald.
|
const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald.
|
||||||
Du stellst einem 7-jährigen Kind den Buchstaben ${letter} vor.
|
Du stellst einem 7-jährigen Kind den Buchstaben ${letter} vor.
|
||||||
@@ -100,28 +84,16 @@ Regeln:
|
|||||||
systemPrompt,
|
systemPrompt,
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
if (!result) {
|
return result ?? getFallbackLetterIntro(letter)?.text ?? "Drücke die leuchtende Taste!";
|
||||||
return (
|
|
||||||
getFallbackLetterIntro(letter)?.text ??
|
|
||||||
"Drücke die leuchtende Taste!"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await incrementApiCall(db, "text");
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getForestComment(
|
export async function getForestComment(
|
||||||
characterType: CompanionType,
|
characterType: CompanionType,
|
||||||
elementDescription: string,
|
elementDescription: string,
|
||||||
db: IDBDatabase,
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
if (!config) return getRandomFallbackForestComment();
|
if (!config) return getRandomFallbackForestComment();
|
||||||
|
|
||||||
const canCall = await checkRateLimit(db, "text");
|
|
||||||
if (!canCall) return getRandomFallbackForestComment();
|
|
||||||
|
|
||||||
const companion = getCompanion(characterType);
|
const companion = getCompanion(characterType);
|
||||||
const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald.
|
const systemPrompt = `Du bist ${companion.name}, ein(e) ${companion.personality} im Zauberwald.
|
||||||
Ein Kind hat gerade eine Tippübung abgeschlossen. Im Wald ist ein neues Wesen erschienen: ${elementDescription}.
|
Ein Kind hat gerade eine Tippübung abgeschlossen. Im Wald ist ein neues Wesen erschienen: ${elementDescription}.
|
||||||
@@ -138,8 +110,5 @@ Regeln:
|
|||||||
systemPrompt,
|
systemPrompt,
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
if (!result) return getRandomFallbackForestComment();
|
return result ?? getRandomFallbackForestComment();
|
||||||
|
|
||||||
await incrementApiCall(db, "text");
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-17
@@ -7,11 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { loadConfig } from "../api/config";
|
import { loadConfig } from "../api/config";
|
||||||
import {
|
import { generateImage } from "../api/gemini";
|
||||||
generateImage,
|
|
||||||
checkRateLimit,
|
|
||||||
incrementApiCall,
|
|
||||||
} from "../api/gemini";
|
|
||||||
import {
|
import {
|
||||||
getStyleReference,
|
getStyleReference,
|
||||||
saveForestElement,
|
saveForestElement,
|
||||||
@@ -93,14 +89,6 @@ async function generateForestElement(db: IDBDatabase): Promise<{
|
|||||||
description: string;
|
description: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
}> {
|
}> {
|
||||||
// Check rate limit
|
|
||||||
const canGenerate = await checkRateLimit(db, "image");
|
|
||||||
if (!canGenerate) {
|
|
||||||
const fallback = await getRandomFallbackImage();
|
|
||||||
return { ...fallback, prompt: "fallback" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load config
|
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
const fallback = await getRandomFallbackImage();
|
const fallback = await getRandomFallbackImage();
|
||||||
@@ -128,9 +116,6 @@ async function generateForestElement(db: IDBDatabase): Promise<{
|
|||||||
return { ...fallback, prompt };
|
return { ...fallback, prompt };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track API usage
|
|
||||||
await incrementApiCall(db, "image");
|
|
||||||
|
|
||||||
return { blob, description: elementType, prompt };
|
return { blob, description: elementType, prompt };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +155,7 @@ export async function initRewardScreen(
|
|||||||
// 3. Get companion comment
|
// 3. Get companion comment
|
||||||
let comment: string;
|
let comment: string;
|
||||||
try {
|
try {
|
||||||
comment = await getForestComment(characterType, result.description, db);
|
comment = await getForestComment(characterType, result.description);
|
||||||
} catch {
|
} catch {
|
||||||
comment = getRandomFallbackForestComment();
|
comment = getRandomFallbackForestComment();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
audioEnabled: false,
|
audioEnabled: false,
|
||||||
speechEnabled: false,
|
speechEnabled: false,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
apiCallsToday: { text: 0, image: 0 },
|
|
||||||
lastApiCallDate: "",
|
|
||||||
};
|
};
|
||||||
updated.audioEnabled = nowEnabled;
|
updated.audioEnabled = nowEnabled;
|
||||||
await saveSettings(db, updated);
|
await saveSettings(db, updated);
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ describe("IndexedDB wrapper", () => {
|
|||||||
audioEnabled: true,
|
audioEnabled: true,
|
||||||
speechEnabled: false,
|
speechEnabled: false,
|
||||||
apiKey: "test-key",
|
apiKey: "test-key",
|
||||||
apiCallsToday: { text: 0, image: 0 },
|
|
||||||
lastApiCallDate: "2026-03-29",
|
|
||||||
};
|
};
|
||||||
await saveSettings(db, settings);
|
await saveSettings(db, settings);
|
||||||
const result = await getSettings(db);
|
const result = await getSettings(db);
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ export interface Settings {
|
|||||||
audioEnabled: boolean;
|
audioEnabled: boolean;
|
||||||
speechEnabled: boolean;
|
speechEnabled: boolean;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
apiCallsToday: { text: number; image: number };
|
|
||||||
lastApiCallDate: string; // ISO date "YYYY-MM-DD"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level definition (used in game/levels.ts)
|
// Level definition (used in game/levels.ts)
|
||||||
|
|||||||
@@ -198,8 +198,6 @@ async function loadSettings(
|
|||||||
audioEnabled: false,
|
audioEnabled: false,
|
||||||
speechEnabled: false,
|
speechEnabled: false,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
apiCallsToday: { text: 0, image: 0 },
|
|
||||||
lastApiCallDate: "",
|
|
||||||
};
|
};
|
||||||
currentSettings.audioEnabled = audioCheckbox.checked;
|
currentSettings.audioEnabled = audioCheckbox.checked;
|
||||||
await saveSettings(db, currentSettings);
|
await saveSettings(db, currentSettings);
|
||||||
@@ -212,8 +210,6 @@ async function loadSettings(
|
|||||||
audioEnabled: false,
|
audioEnabled: false,
|
||||||
speechEnabled: false,
|
speechEnabled: false,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
apiCallsToday: { text: 0, image: 0 },
|
|
||||||
lastApiCallDate: "",
|
|
||||||
};
|
};
|
||||||
currentSettings.apiKey = apiKeyInput.value;
|
currentSettings.apiKey = apiKeyInput.value;
|
||||||
await saveSettings(db, currentSettings);
|
await saveSettings(db, currentSettings);
|
||||||
|
|||||||
+36
-2
@@ -92,19 +92,54 @@ export function initWelcomeScreen(
|
|||||||
|
|
||||||
// Finger description for companion letter intros
|
// Finger description for companion letter intros
|
||||||
const fingerDescriptions: Record<string, string> = {
|
const fingerDescriptions: Record<string, string> = {
|
||||||
|
// Level 1: Home row
|
||||||
f: "linker Zeigefinger",
|
f: "linker Zeigefinger",
|
||||||
j: "rechter Zeigefinger",
|
j: "rechter Zeigefinger",
|
||||||
|
" ": "Daumen",
|
||||||
|
// Level 2
|
||||||
d: "linker Mittelfinger",
|
d: "linker Mittelfinger",
|
||||||
k: "rechter Mittelfinger",
|
k: "rechter Mittelfinger",
|
||||||
|
// Level 3
|
||||||
s: "linker Ringfinger",
|
s: "linker Ringfinger",
|
||||||
l: "rechter Ringfinger",
|
l: "rechter Ringfinger",
|
||||||
|
// Level 4
|
||||||
a: "linker kleiner Finger",
|
a: "linker kleiner Finger",
|
||||||
ö: "rechter kleiner Finger",
|
ö: "rechter kleiner Finger",
|
||||||
|
// Level 5
|
||||||
g: "linker Zeigefinger (Streckung)",
|
g: "linker Zeigefinger (Streckung)",
|
||||||
h: "rechter Zeigefinger (Streckung)",
|
h: "rechter Zeigefinger (Streckung)",
|
||||||
|
// Level 6
|
||||||
e: "linker Mittelfinger (obere Reihe)",
|
e: "linker Mittelfinger (obere Reihe)",
|
||||||
i: "rechter Mittelfinger (obere Reihe)",
|
i: "rechter Mittelfinger (obere Reihe)",
|
||||||
" ": "Daumen",
|
// Level 7
|
||||||
|
r: "linker Zeigefinger (obere Reihe)",
|
||||||
|
u: "rechter Zeigefinger (obere Reihe)",
|
||||||
|
// Level 8
|
||||||
|
t: "linker Zeigefinger (obere Reihe, Streckung)",
|
||||||
|
z: "rechter Zeigefinger (obere Reihe, Streckung)",
|
||||||
|
// Level 9
|
||||||
|
w: "linker Ringfinger (obere Reihe)",
|
||||||
|
o: "rechter Ringfinger (obere Reihe)",
|
||||||
|
// Level 10
|
||||||
|
q: "linker kleiner Finger (obere Reihe)",
|
||||||
|
p: "rechter kleiner Finger (obere Reihe)",
|
||||||
|
// Level 11
|
||||||
|
n: "rechter Zeigefinger (untere Reihe)",
|
||||||
|
b: "linker Zeigefinger (untere Reihe)",
|
||||||
|
// Level 12
|
||||||
|
v: "linker Zeigefinger (untere Reihe)",
|
||||||
|
m: "rechter Zeigefinger (untere Reihe)",
|
||||||
|
// Level 13
|
||||||
|
c: "linker Mittelfinger (untere Reihe)",
|
||||||
|
x: "linker Ringfinger (untere Reihe)",
|
||||||
|
// Level 14
|
||||||
|
ä: "rechter kleiner Finger",
|
||||||
|
ü: "rechter kleiner Finger",
|
||||||
|
// Level 15
|
||||||
|
y: "linker kleiner Finger (untere Reihe)",
|
||||||
|
",": "rechter Mittelfinger (untere Reihe)",
|
||||||
|
// Level 16
|
||||||
|
".": "rechter Ringfinger (untere Reihe)",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Encouragement messages for repetition protection
|
// Encouragement messages for repetition protection
|
||||||
@@ -417,7 +452,6 @@ export function initLessonScreen(
|
|||||||
characterType,
|
characterType,
|
||||||
key.toUpperCase(),
|
key.toUpperCase(),
|
||||||
fingerDesc,
|
fingerDesc,
|
||||||
db,
|
|
||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user