# Foto-Rezept-Magie Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Nutzer kann ein Foto eines gedruckten oder handgeschriebenen Rezepts aufnehmen — Gemini 2.5 Flash extrahiert die Felder, der Nutzer landet im vorausgefüllten `RecipeEditor` und speichert. **Architecture:** Foto geht per Multipart an einen neuen Server-Endpoint, der es mit `sharp` runterskaliert, einmal an Gemini schickt (structured output + Zod-validiert, 1× Retry) und das extrahierte `Partial` zurückgibt — **ohne** DB-Write. Der Client hält das Ergebnis in State und rendert den bestehenden `RecipeEditor` mit `recipe.id === null`. Auf Save POSTet der Client die kompletten Felder an einen neuen `POST /api/recipes`-Endpoint, der `insertRecipe` wrappt. Weder das Original-Foto noch die AI-Payload werden persistiert oder geloggt. **Tech Stack:** SvelteKit 2, Svelte 5, better-sqlite3, `sharp` (Image-Preprocess), `@google/generative-ai` (Gemini-SDK), `zod` (Validierung), Vitest (Unit + API), Playwright (E2E). **Spec reference:** `docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md` --- ## Task 1: Dependencies & Env-Config **Files:** - Modify: `package.json` (add `@google/generative-ai`, `sharp`) - Modify: `Dockerfile:3-10` (ergänze build deps für `sharp`) - Modify: `.env.example`, `docker-compose.yml`, `docker-compose.prod.yml` - Create: (nothing new) Dies ist **Infrastruktur-Setup**. Keine Tests — die Deps-Installation verifiziert sich von selbst, der Dockerfile-Build durch CI im Follow-up. - [ ] **Step 1: Deps installieren** ```bash npm install @google/generative-ai sharp ``` Expected: beide in `dependencies` in `package.json`, `package-lock.json` aktualisiert. - [ ] **Step 2: Dockerfile erweitern für sharp-arm64** Ersetze Zeile 7 (`RUN apk add --no-cache python3 make g++ libc6-compat`) durch: ```dockerfile # Alpine braucht Build-Tools für better-sqlite3 (native) und sharp (libvips); # vips-dev liefert libheif-Support für HEIC-Input von iOS. RUN apk add --no-cache python3 make g++ libc6-compat vips-dev ``` Runtime-Stage (Zeile 21) bleibt `libc6-compat` — sharp linkt libvips statisch in seinen npm-Prebuilds. - [ ] **Step 3: Env-Vars ergänzen** In `.env.example` ergänzen: ``` # Gemini Vision (Foto-Rezept-Magie). Ohne Key ist die Funktion deaktiviert. GEMINI_API_KEY= GEMINI_MODEL=gemini-2.5-flash GEMINI_TIMEOUT_MS=20000 ``` In `docker-compose.yml` und `docker-compose.prod.yml` im `environment`-Block des App-Services die drei Variablen ergänzen: ```yaml environment: # …bestehende Einträge… GEMINI_API_KEY: ${GEMINI_API_KEY:-} GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash} GEMINI_TIMEOUT_MS: ${GEMINI_TIMEOUT_MS:-20000} ``` - [ ] **Step 4: Build verifizieren** ```bash npm run check ``` Expected: PASS (keine neuen Type-Fehler durch die neuen Packages — sie werden noch nicht importiert). - [ ] **Step 5: Commit** ```bash git add package.json package-lock.json Dockerfile .env.example docker-compose.yml docker-compose.prod.yml git commit -m "chore(deps): Gemini-SDK und sharp fuer Foto-Rezept-Magie" ``` --- ## Task 2: Description-Phrasen-Pool **Files:** - Create: `src/lib/server/ai/description-phrases.ts` - Create: `src/lib/server/ai/description-phrases.test.ts` Isolierter, reiner Helper. TDD: erst der Test, dann das Modul. - [ ] **Step 1: Failing test schreiben** `src/lib/server/ai/description-phrases.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import { DESCRIPTION_PHRASES, pickRandomPhrase } from './description-phrases'; describe('description-phrases', () => { it('contains exactly 50 entries', () => { expect(DESCRIPTION_PHRASES).toHaveLength(50); }); it('has no empty or whitespace-only entries', () => { for (const phrase of DESCRIPTION_PHRASES) { expect(phrase.trim().length).toBeGreaterThan(0); } }); it('has no duplicates', () => { const set = new Set(DESCRIPTION_PHRASES); expect(set.size).toBe(DESCRIPTION_PHRASES.length); }); it('pickRandomPhrase returns a member of the pool', () => { const pool = new Set(DESCRIPTION_PHRASES); for (let i = 0; i < 100; i++) { expect(pool.has(pickRandomPhrase())).toBe(true); } }); }); ``` - [ ] **Step 2: Test laufen lassen (erwartet FAIL)** ```bash npx vitest run src/lib/server/ai/description-phrases.test.ts ``` Expected: FAIL — Modul existiert noch nicht. - [ ] **Step 3: Modul implementieren** `src/lib/server/ai/description-phrases.ts`: ```ts export const DESCRIPTION_PHRASES: readonly string[] = [ 'Mit dem Zauberstab aus dem Kochbuch geholt.', 'Foto-Magie frisch aus dem Ofen.', 'Aus dem Bild herbeigezaubert.', 'Ein Klick, ein Foto, fertig.', 'Knipsen statt Abtippen.', 'Von der Buchseite direkt in die Pfanne.', 'Die Kamera hat mitgelesen.', 'Abrakadabra — Rezept da.', 'Per Linse in die Küche teleportiert.', 'Von Oma abfotografiert, von der KI entziffert.', 'Frisch aus dem Bilderrahmen.', 'Klick, zisch, Rezept.', 'Das Foto wurde überredet, sich zu verraten.', 'Schnappschuss zur Schüssel.', 'Einmal lesen lassen, schon da.', 'Keine Hand hat dieses Rezept abgetippt.', 'Vom Bild in die Bratpfanne.', 'Papier ist geduldig, das Foto war es auch.', 'Eine Seite, ein Foto, ein Rezept.', 'Die KI hat drübergeschielt.', 'Handschriftlich entziffert — oder zumindest versucht.', 'Aus der Linse in die Liste.', 'Vom Küchentisch zur Kachel.', 'Knips und weg — zumindest der Zettel.', 'Das Bild hat geredet.', 'Keine Tippfehler, nur Sehfehler.', 'Per Foto eingebürgert.', 'Rezept-Übersetzung aus dem Bild.', 'Die Seite hat sich verraten.', 'Blitzlicht und dann Gulasch.', 'Ein Augenzwinkern der Kamera genügte.', 'Geknipst, gelesen, gespeichert.', 'Fotografische Gedächtnishilfe.', 'Aus der Schublade ans Licht.', 'Das Rezept stand schon da — wir haben nur hingeguckt.', 'Zaubertrick mit Kamera.', 'Vom Papier befreit.', 'Ein Foto sagt mehr als tausend Zutatenlisten.', 'Eingescannt, rausgelesen, reingeschrieben.', 'Die Kamera als Küchenhilfe.', 'Handy hoch, Rezept runter.', 'Aus dem Kochbuch gebeamt.', 'Ein scharfes Foto, ein klares Rezept.', 'Vom Regal zur App in einem Schritt.', 'Aus dem Bild geschöpft wie Suppe aus dem Topf.', 'Optisch erfasst, digital serviert.', 'Das Kleingedruckte hat die KI gelesen.', 'Vom Kladdenzettel in die Datenbank.', 'Kurz gezückt, schon gekocht.', 'Kein Schreibkrampf, nur ein Klick.' ]; export function pickRandomPhrase(): string { return DESCRIPTION_PHRASES[Math.floor(Math.random() * DESCRIPTION_PHRASES.length)]; } ``` - [ ] **Step 4: Test laufen lassen (erwartet PASS)** ```bash npx vitest run src/lib/server/ai/description-phrases.test.ts ``` Expected: PASS — alle 4 Tests grün. - [ ] **Step 5: Commit** ```bash git add src/lib/server/ai/description-phrases.ts src/lib/server/ai/description-phrases.test.ts git commit -m "feat(ai): 50er-Pool Magie-Phrasen fuer Foto-description" ``` --- ## Task 3: Image-Preprocess (sharp-Wrapper) **Files:** - Create: `src/lib/server/ai/image-preprocess.ts` - Create: `src/lib/server/ai/image-preprocess.test.ts` Reine Funktion: Buffer rein, Buffer raus. Keine Side-Effects, keine I/O außerhalb von `sharp`. - [ ] **Step 1: Failing tests schreiben** `src/lib/server/ai/image-preprocess.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import sharp from 'sharp'; import { preprocessImage } from './image-preprocess'; async function makeTestImage(width: number, height: number, format: 'jpeg' | 'png' | 'webp' = 'jpeg'): Promise { return sharp({ create: { width, height, channels: 3, background: { r: 128, g: 128, b: 128 } } }) .toFormat(format) .toBuffer(); } describe('preprocessImage', () => { it('resizes a landscape image so long edge <= 1600px', async () => { const input = await makeTestImage(4000, 2000); const { buffer, mimeType } = await preprocessImage(input); const meta = await sharp(buffer).metadata(); expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600); expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeGreaterThan(1000); expect(mimeType).toBe('image/jpeg'); }); it('resizes a portrait image so long edge <= 1600px', async () => { const input = await makeTestImage(2000, 4000); const { buffer } = await preprocessImage(input); const meta = await sharp(buffer).metadata(); expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(1600); }); it('does not upscale smaller images', async () => { const input = await makeTestImage(800, 600); const { buffer } = await preprocessImage(input); const meta = await sharp(buffer).metadata(); expect(meta.width).toBe(800); expect(meta.height).toBe(600); }); it('converts PNG input to JPEG output', async () => { const input = await makeTestImage(1000, 1000, 'png'); const { buffer, mimeType } = await preprocessImage(input); const meta = await sharp(buffer).metadata(); expect(meta.format).toBe('jpeg'); expect(mimeType).toBe('image/jpeg'); }); it('strips EXIF metadata', async () => { const input = await sharp({ create: { width: 100, height: 100, channels: 3, background: '#888' } }) .withMetadata({ exif: { IFD0: { Copyright: 'test' } } }) .jpeg() .toBuffer(); const { buffer } = await preprocessImage(input); const meta = await sharp(buffer).metadata(); expect(meta.exif).toBeUndefined(); }); it('rejects non-image buffers', async () => { const notAnImage = Buffer.from('hello world'); await expect(preprocessImage(notAnImage)).rejects.toThrow(); }); }); ``` - [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** ```bash npx vitest run src/lib/server/ai/image-preprocess.test.ts ``` Expected: FAIL — Modul existiert noch nicht. - [ ] **Step 3: Modul implementieren** `src/lib/server/ai/image-preprocess.ts`: ```ts import sharp from 'sharp'; const MAX_EDGE = 1600; const JPEG_QUALITY = 85; export type PreprocessedImage = { buffer: Buffer; mimeType: 'image/jpeg'; }; // Resize auf max 1600px lange Kante, JPEG re-encode, Metadata strippen. // sharp liest HEIC/HEIF transparent wenn libheif/libvips mit Support gebaut wurde // (ist in Alpine's vips-dev enthalten). export async function preprocessImage(input: Buffer): Promise { const pipeline = sharp(input, { failOn: 'error' }).rotate(); // respect EXIF orientation const meta = await pipeline.metadata(); if (!meta.width || !meta.height) { throw new Error('Unable to read image dimensions'); } const longEdge = Math.max(meta.width, meta.height); const resizer = longEdge > MAX_EDGE ? pipeline.resize({ width: meta.width >= meta.height ? MAX_EDGE : undefined, height: meta.height > meta.width ? MAX_EDGE : undefined, withoutEnlargement: true }) : pipeline; const buffer = await resizer .jpeg({ quality: JPEG_QUALITY, mozjpeg: true }) .withMetadata({}) // drops EXIF/IPTC/XMP .toBuffer(); return { buffer, mimeType: 'image/jpeg' }; } ``` - [ ] **Step 4: Tests laufen lassen (erwartet PASS)** ```bash npx vitest run src/lib/server/ai/image-preprocess.test.ts ``` Expected: PASS — alle 6 Tests grün. Falls der EXIF-Strip-Test fehlschlägt: sharp's `withMetadata({})` muss leere Options haben, nicht weglassen. - [ ] **Step 5: Commit** ```bash git add src/lib/server/ai/image-preprocess.ts src/lib/server/ai/image-preprocess.test.ts git commit -m "feat(ai): image-preprocess mit sharp (Resize + JPEG-Strip)" ``` --- ## Task 4: Extraction-Prompt + JSON-Schema + Zod-Validator **Files:** - Create: `src/lib/server/ai/recipe-extraction-prompt.ts` - Create: `src/lib/server/ai/recipe-extraction-prompt.test.ts` Hier liegt der extrahierte Prompt-Text plus das Gemini-`responseSchema` plus das Zod-Spiegelbild davon. Tests gegen die Zod-Validierung — den Gemini-Call selbst testen wir erst in Task 5. - [ ] **Step 1: Failing tests schreiben** `src/lib/server/ai/recipe-extraction-prompt.test.ts`: ```ts import { describe, it, expect } from 'vitest'; import { RECIPE_EXTRACTION_SYSTEM_PROMPT, GEMINI_RESPONSE_SCHEMA, extractionResponseSchema, type ExtractionResponse } from './recipe-extraction-prompt'; describe('recipe-extraction-prompt', () => { it('system prompt is in German and mentions title+ingredients+steps', () => { const p = RECIPE_EXTRACTION_SYSTEM_PROMPT.toLowerCase(); expect(p).toContain('rezept'); expect(p).toContain('zutaten'); expect(p).toContain('zubereitung'); }); it('Gemini response schema has required top-level keys', () => { expect(GEMINI_RESPONSE_SCHEMA.type).toBe('object'); expect(Object.keys(GEMINI_RESPONSE_SCHEMA.properties)).toEqual( expect.arrayContaining(['title', 'ingredients', 'steps']) ); }); it('Zod validator accepts a well-formed response', () => { const good: ExtractionResponse = { title: 'Testrezept', servings_default: 4, servings_unit: 'Portionen', prep_time_min: 15, cook_time_min: 30, total_time_min: null, ingredients: [ { quantity: 100, unit: 'g', name: 'Mehl', note: null } ], steps: [ { text: 'Mehl in eine Schüssel geben.' } ] }; expect(() => extractionResponseSchema.parse(good)).not.toThrow(); }); it('Zod validator rejects missing title', () => { const bad = { servings_default: 4, ingredients: [], steps: [] }; expect(() => extractionResponseSchema.parse(bad)).toThrow(); }); it('Zod validator coerces quantity=null and unit=null', () => { const ok: ExtractionResponse = { title: 'Prise-Rezept', servings_default: null, servings_unit: null, prep_time_min: null, cook_time_min: null, total_time_min: null, ingredients: [{ quantity: null, unit: null, name: 'Salz', note: 'nach Geschmack' }], steps: [{ text: 'Einfach so.' }] }; expect(() => extractionResponseSchema.parse(ok)).not.toThrow(); }); it('Zod validator rejects unexpected extra top-level keys (strict)', () => { const bad = { title: 'x', servings_default: null, servings_unit: null, prep_time_min: null, cook_time_min: null, total_time_min: null, ingredients: [], steps: [], malicious_extra_field: 'pwned' }; expect(() => extractionResponseSchema.parse(bad)).toThrow(); }); }); ``` - [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** ```bash npx vitest run src/lib/server/ai/recipe-extraction-prompt.test.ts ``` Expected: FAIL — Modul existiert noch nicht. - [ ] **Step 3: Modul implementieren** `src/lib/server/ai/recipe-extraction-prompt.ts`: ```ts import { z } from 'zod'; import { SchemaType } from '@google/generative-ai'; // Hinweis: Das Gemini-SDK nutzt eigene Schema-Konstanten (SchemaType.STRING etc.). // Wir halten das Schema hier rein-JS und konvertieren erst im Client. export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein Rezept-Extraktions-Assistent. Du bekommst ein Foto eines gedruckten oder handgeschriebenen Rezepts und gibst ein strukturiertes JSON zurück. Regeln: - Extrahiere nur, was tatsächlich auf dem Bild lesbar ist. Sonst Feld auf null (oder leeres Array). - Zutaten: quantity als Zahl (Bruchteile wie ½, ¼, 1 ½ als Dezimalzahl 0.5, 0.25, 1.5), unit separat (g, ml, l, kg, EL, TL, Stück, Prise, Msp, …). - Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt. - Zeiten in Minuten (ganze Zahl). "1 Stunde" = 60. - Ignoriere Werbung, Foto-Bildunterschriften, Einleitungstexte. Nur das Rezept selbst. - Denke dir NICHTS dazu aus. Was nicht auf dem Bild steht, ist null. - Antworte ausschließlich im vorgegebenen JSON-Schema. Kein Markdown, kein Prosa-Text.`; // Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent // übergeben; Gemini respektiert die Struktur und liefert garantiert valides JSON. export const GEMINI_RESPONSE_SCHEMA = { type: SchemaType.OBJECT, properties: { title: { type: SchemaType.STRING, nullable: false }, servings_default: { type: SchemaType.INTEGER, nullable: true }, servings_unit: { type: SchemaType.STRING, nullable: true }, prep_time_min: { type: SchemaType.INTEGER, nullable: true }, cook_time_min: { type: SchemaType.INTEGER, nullable: true }, total_time_min: { type: SchemaType.INTEGER, nullable: true }, ingredients: { type: SchemaType.ARRAY, items: { type: SchemaType.OBJECT, properties: { quantity: { type: SchemaType.NUMBER, nullable: true }, unit: { type: SchemaType.STRING, nullable: true }, name: { type: SchemaType.STRING, nullable: false }, note: { type: SchemaType.STRING, nullable: true } }, required: ['name'] } }, steps: { type: SchemaType.ARRAY, items: { type: SchemaType.OBJECT, properties: { text: { type: SchemaType.STRING, nullable: false } }, required: ['text'] } } }, required: ['title', 'ingredients', 'steps'] } as const; // Zod-Spiegel des Schemas für server-seitige Validierung der Gemini-Antwort. // .strict() verhindert, dass zusätzliche Keys (die Gemini theoretisch erfinden könnte) // unbemerkt durchrutschen. const ingredientSchema = z.object({ quantity: z.number().nullable(), unit: z.string().max(30).nullable(), name: z.string().min(1).max(200), note: z.string().max(300).nullable() }).strict(); const stepSchema = z.object({ text: z.string().min(1).max(4000) }).strict(); export const extractionResponseSchema = z.object({ title: z.string().min(1).max(200), servings_default: z.number().int().nonnegative().nullable(), servings_unit: z.string().max(30).nullable(), prep_time_min: z.number().int().nonnegative().nullable(), cook_time_min: z.number().int().nonnegative().nullable(), total_time_min: z.number().int().nonnegative().nullable(), ingredients: z.array(ingredientSchema), steps: z.array(stepSchema) }).strict(); export type ExtractionResponse = z.infer; ``` - [ ] **Step 4: Tests laufen lassen (erwartet PASS)** ```bash npx vitest run src/lib/server/ai/recipe-extraction-prompt.test.ts ``` Expected: PASS — alle 6 Tests grün. - [ ] **Step 5: Commit** ```bash git add src/lib/server/ai/recipe-extraction-prompt.ts src/lib/server/ai/recipe-extraction-prompt.test.ts git commit -m "feat(ai): Extraction-Prompt + Gemini-Schema + Zod-Validator" ``` --- ## Task 5: Gemini-Client (Call, Timeout, Retry) **Files:** - Create: `src/lib/server/ai/gemini-client.ts` - Create: `src/lib/server/ai/gemini-client.test.ts` Dünne Wrapper-Schicht, deren einziger Job ist: Image-Buffer + MIME → `ExtractionResponse` (oder wohldefinierter Fehler). Tests mocken das Gemini-SDK. - [ ] **Step 1: Failing tests schreiben** `src/lib/server/ai/gemini-client.test.ts`: ```ts import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock das Gemini-SDK bevor der Client importiert wird. const mockGenerateContent = vi.fn(); vi.mock('@google/generative-ai', async () => { const actual = await vi.importActual('@google/generative-ai'); return { ...actual, GoogleGenerativeAI: vi.fn().mockImplementation(() => ({ getGenerativeModel: () => ({ generateContent: mockGenerateContent }) })) }; }); import { extractRecipeFromImage, GeminiError } from './gemini-client'; beforeEach(() => { mockGenerateContent.mockReset(); process.env.GEMINI_API_KEY = 'test-key'; process.env.GEMINI_MODEL = 'gemini-2.5-flash'; process.env.GEMINI_TIMEOUT_MS = '5000'; }); const validResponse = { title: 'Apfelkuchen', servings_default: 8, servings_unit: 'Stück', prep_time_min: 20, cook_time_min: 45, total_time_min: null, ingredients: [{ quantity: 500, unit: 'g', name: 'Mehl', note: null }], steps: [{ text: 'Ofen auf 180 °C vorheizen.' }] }; describe('extractRecipeFromImage', () => { it('happy path: returns parsed recipe data', async () => { mockGenerateContent.mockResolvedValueOnce({ response: { text: () => JSON.stringify(validResponse) } }); const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'); expect(result.title).toBe('Apfelkuchen'); expect(result.ingredients).toHaveLength(1); }); it('retries once on schema-invalid JSON, then succeeds', async () => { mockGenerateContent .mockResolvedValueOnce({ response: { text: () => '{"title": "no arrays"}' } }) .mockResolvedValueOnce({ response: { text: () => JSON.stringify(validResponse) } }); const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'); expect(result.title).toBe('Apfelkuchen'); expect(mockGenerateContent).toHaveBeenCalledTimes(2); }); it('throws AI_FAILED after schema-invalid + retry also invalid', async () => { mockGenerateContent .mockResolvedValueOnce({ response: { text: () => '{}' } }) .mockResolvedValueOnce({ response: { text: () => '{"title": "still bad"}' } }); await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) .rejects.toMatchObject({ code: 'AI_FAILED' }); }); it('throws AI_RATE_LIMITED without retry on 429', async () => { const err = new Error('429 Too Many Requests') as Error & { status?: number }; err.status = 429; mockGenerateContent.mockRejectedValueOnce(err); await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) .rejects.toMatchObject({ code: 'AI_RATE_LIMITED' }); expect(mockGenerateContent).toHaveBeenCalledTimes(1); }); it('retries once on 5xx, then succeeds', async () => { const err = new Error('500 Server Error') as Error & { status?: number }; err.status = 500; mockGenerateContent .mockRejectedValueOnce(err) .mockResolvedValueOnce({ response: { text: () => JSON.stringify(validResponse) } }); const result = await extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg'); expect(result.title).toBe('Apfelkuchen'); expect(mockGenerateContent).toHaveBeenCalledTimes(2); }); it('throws AI_FAILED after 5xx + retry also fails', async () => { const err = new Error('500') as Error & { status?: number }; err.status = 500; mockGenerateContent.mockRejectedValue(err); await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) .rejects.toMatchObject({ code: 'AI_FAILED' }); expect(mockGenerateContent).toHaveBeenCalledTimes(2); }); it('throws AI_TIMEOUT when generateContent does not resolve within GEMINI_TIMEOUT_MS', async () => { process.env.GEMINI_TIMEOUT_MS = '50'; mockGenerateContent.mockImplementation(() => new Promise(() => {})); // never resolves await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) .rejects.toMatchObject({ code: 'AI_TIMEOUT' }); }); it('throws AI_NOT_CONFIGURED when GEMINI_API_KEY is empty', async () => { process.env.GEMINI_API_KEY = ''; await expect(extractRecipeFromImage(Buffer.from('fake'), 'image/jpeg')) .rejects.toMatchObject({ code: 'AI_NOT_CONFIGURED' }); }); }); ``` - [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** ```bash npx vitest run src/lib/server/ai/gemini-client.test.ts ``` Expected: FAIL — Modul existiert noch nicht. - [ ] **Step 3: Modul implementieren** `src/lib/server/ai/gemini-client.ts`: ```ts import { GoogleGenerativeAI } from '@google/generative-ai'; import { env } from '$env/dynamic/private'; import { RECIPE_EXTRACTION_SYSTEM_PROMPT, GEMINI_RESPONSE_SCHEMA, extractionResponseSchema, type ExtractionResponse } from './recipe-extraction-prompt'; export type GeminiErrorCode = | 'AI_NOT_CONFIGURED' | 'AI_RATE_LIMITED' | 'AI_TIMEOUT' | 'AI_FAILED'; export class GeminiError extends Error { constructor(public readonly code: GeminiErrorCode, message: string) { super(message); this.name = 'GeminiError'; } } function getStatus(err: unknown): number | undefined { if (err && typeof err === 'object' && 'status' in err) { const s = (err as { status?: unknown }).status; if (typeof s === 'number') return s; } return undefined; } async function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new GeminiError('AI_TIMEOUT', `Gemini timeout after ${ms} ms`)), ms); promise.then( (v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); } ); }); } // Call Gemini once. Throws GeminiError on known failures, rethrows network/status errors. async function callGemini( imageBuffer: Buffer, mimeType: string, appendUserNote?: string ): Promise { const apiKey = env.GEMINI_API_KEY ?? process.env.GEMINI_API_KEY; if (!apiKey) { throw new GeminiError('AI_NOT_CONFIGURED', 'GEMINI_API_KEY is not set'); } const modelId = env.GEMINI_MODEL ?? process.env.GEMINI_MODEL ?? 'gemini-2.5-flash'; const timeoutMs = Number(env.GEMINI_TIMEOUT_MS ?? process.env.GEMINI_TIMEOUT_MS ?? 20000); const client = new GoogleGenerativeAI(apiKey); const model = client.getGenerativeModel({ model: modelId, systemInstruction: RECIPE_EXTRACTION_SYSTEM_PROMPT, generationConfig: { temperature: 0.1, responseMimeType: 'application/json', // eslint-disable-next-line @typescript-eslint/no-explicit-any responseSchema: GEMINI_RESPONSE_SCHEMA as any } }); const parts: Array<{ inlineData: { data: string; mimeType: string } } | { text: string }> = [ { inlineData: { data: imageBuffer.toString('base64'), mimeType } } ]; if (appendUserNote) parts.push({ text: appendUserNote }); const result = await withTimeout( model.generateContent({ contents: [{ role: 'user', parts }] }), timeoutMs ); const text = result.response.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { throw new GeminiError('AI_FAILED', 'Gemini returned non-JSON output'); } const validated = extractionResponseSchema.safeParse(parsed); if (!validated.success) { throw new GeminiError('AI_FAILED', `Schema validation failed: ${validated.error.message}`); } return validated.data; } // Public entry: one retry on recoverable failures (5xx OR schema-invalid), // no retry on 429 or config errors. export async function extractRecipeFromImage( imageBuffer: Buffer, mimeType: string ): Promise { try { return await callGemini(imageBuffer, mimeType); } catch (e) { if (e instanceof GeminiError && e.code === 'AI_NOT_CONFIGURED') throw e; if (e instanceof GeminiError && e.code === 'AI_TIMEOUT') throw e; const status = getStatus(e); if (status === 429) throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit'); const recoverable = (e instanceof GeminiError && e.code === 'AI_FAILED') || (status !== undefined && status >= 500); if (!recoverable) { throw e instanceof GeminiError ? e : new GeminiError('AI_FAILED', String(e)); } // 1x retry await new Promise((r) => setTimeout(r, 500)); try { return await callGemini( imageBuffer, mimeType, 'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.' ); } catch (retryErr) { if (retryErr instanceof GeminiError) throw retryErr; const retryStatus = getStatus(retryErr); if (retryStatus === 429) throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry'); throw new GeminiError('AI_FAILED', String(retryErr)); } } } ``` - [ ] **Step 4: Tests laufen lassen (erwartet PASS)** ```bash npx vitest run src/lib/server/ai/gemini-client.test.ts ``` Expected: PASS — alle 8 Tests grün. Falls Tests aufgrund des Env-Mocks flakig sind: sichergehen dass `process.env` im `beforeEach` rein sauber gesetzt wird. - [ ] **Step 5: Commit** ```bash git add src/lib/server/ai/gemini-client.ts src/lib/server/ai/gemini-client.test.ts git commit -m "feat(ai): Gemini-Client mit Timeout, 1x-Retry und Fehler-Codes" ``` --- ## Task 6: Rate-Limiter (In-Memory, pro IP) **Files:** - Create: `src/lib/server/ai/rate-limit.ts` - Create: `src/lib/server/ai/rate-limit.test.ts` Einfacher `Map` — 10 Requests/Minute pro IP. - [ ] **Step 1: Failing tests schreiben** `src/lib/server/ai/rate-limit.test.ts`: ```ts import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createRateLimiter } from './rate-limit'; describe('rate-limit', () => { beforeEach(() => vi.useFakeTimers()); it('allows first 10 requests, rejects 11th', () => { const limiter = createRateLimiter({ windowMs: 60_000, max: 10 }); for (let i = 0; i < 10; i++) expect(limiter.check('1.2.3.4')).toBe(true); expect(limiter.check('1.2.3.4')).toBe(false); }); it('tracks per-IP independently', () => { const limiter = createRateLimiter({ windowMs: 60_000, max: 2 }); expect(limiter.check('a')).toBe(true); expect(limiter.check('a')).toBe(true); expect(limiter.check('a')).toBe(false); expect(limiter.check('b')).toBe(true); }); it('resets after window elapses', () => { const limiter = createRateLimiter({ windowMs: 1000, max: 1 }); expect(limiter.check('x')).toBe(true); expect(limiter.check('x')).toBe(false); vi.advanceTimersByTime(1001); expect(limiter.check('x')).toBe(true); }); }); ``` - [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** ```bash npx vitest run src/lib/server/ai/rate-limit.test.ts ``` Expected: FAIL. - [ ] **Step 3: Modul implementieren** `src/lib/server/ai/rate-limit.ts`: ```ts export type RateLimiter = { check: (key: string) => boolean }; export function createRateLimiter(opts: { windowMs: number; max: number }): RateLimiter { const store = new Map(); return { check(key: string): boolean { const now = Date.now(); const entry = store.get(key); if (!entry || entry.resetAt <= now) { store.set(key, { count: 1, resetAt: now + opts.windowMs }); return true; } if (entry.count >= opts.max) return false; entry.count += 1; return true; } }; } ``` - [ ] **Step 4: Tests laufen lassen (erwartet PASS)** ```bash npx vitest run src/lib/server/ai/rate-limit.test.ts ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/lib/server/ai/rate-limit.ts src/lib/server/ai/rate-limit.test.ts git commit -m "feat(ai): simpler In-Memory-Ratelimiter pro IP" ``` --- ## Task 7: POST /api/recipes/extract-from-photo **Files:** - Create: `src/routes/api/recipes/extract-from-photo/+server.ts` - Create: `src/routes/api/recipes/extract-from-photo/+server.test.ts` Verknüpft alle vorigen Module: Validierung → preprocess → Gemini → Phrase → Response. - [ ] **Step 1: Failing tests schreiben** `src/routes/api/recipes/extract-from-photo/+server.test.ts`: ```ts import { describe, it, expect, vi, beforeEach } from 'vitest'; import sharp from 'sharp'; // Mock den Gemini-Client bevor der Endpoint importiert wird. const mockExtract = vi.fn(); vi.mock('$lib/server/ai/gemini-client', () => ({ extractRecipeFromImage: mockExtract, GeminiError: class GeminiError extends Error { constructor(public readonly code: string, message: string) { super(message); } } })); import { POST } from './+server'; import { GeminiError } from '$lib/server/ai/gemini-client'; async function makeJpeg(): Promise { return sharp({ create: { width: 100, height: 100, channels: 3, background: '#888' } }) .jpeg() .toBuffer(); } function mkRequest(body: FormData, ip = '1.2.3.4'): Parameters[0] { return { request: new Request('http://test/api/recipes/extract-from-photo', { method: 'POST', body }), getClientAddress: () => ip } as unknown as Parameters[0]; } beforeEach(() => { mockExtract.mockReset(); process.env.GEMINI_API_KEY = 'test-key'; }); const validAiResponse = { title: 'Testrezept', servings_default: 4, servings_unit: 'Portionen', prep_time_min: 10, cook_time_min: 20, total_time_min: null, ingredients: [{ quantity: 1, unit: null, name: 'Apfel', note: null }], steps: [{ text: 'Apfel schälen.' }] }; describe('POST /api/recipes/extract-from-photo', () => { it('happy path: 200 with recipe shape', async () => { mockExtract.mockResolvedValueOnce(validAiResponse); const fd = new FormData(); fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' }), 'x.jpg'); const res = await POST(mkRequest(fd)); expect(res.status).toBe(200); const body = await res.json(); expect(body.recipe.title).toBe('Testrezept'); expect(body.recipe.description).toMatch(/\w/); // random phrase, non-empty expect(body.recipe.image_path).toBeNull(); expect(body.recipe.ingredients[0].raw_text).toContain('Apfel'); }); it('413 when file exceeds 8 MB', async () => { const big = Buffer.alloc(9 * 1024 * 1024); const fd = new FormData(); fd.append('photo', new Blob([big], { type: 'image/jpeg' })); const res = await POST(mkRequest(fd)); expect(res.status).toBe(413); expect((await res.json()).code).toBe('PAYLOAD_TOO_LARGE'); }); it('415 when content-type not in whitelist', async () => { const fd = new FormData(); fd.append('photo', new Blob([Buffer.from('hi')], { type: 'text/plain' })); const res = await POST(mkRequest(fd)); expect(res.status).toBe(415); expect((await res.json()).code).toBe('UNSUPPORTED_MEDIA_TYPE'); }); it('400 when no photo field', async () => { const fd = new FormData(); const res = await POST(mkRequest(fd)); expect(res.status).toBe(400); }); it('422 NO_RECIPE_IN_IMAGE when 0 ingredients AND 0 steps', async () => { mockExtract.mockResolvedValueOnce({ ...validAiResponse, ingredients: [], steps: [] }); const fd = new FormData(); fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); const res = await POST(mkRequest(fd)); expect(res.status).toBe(422); expect((await res.json()).code).toBe('NO_RECIPE_IN_IMAGE'); }); it('503 AI_NOT_CONFIGURED when GEMINI_API_KEY empty', async () => { process.env.GEMINI_API_KEY = ''; mockExtract.mockRejectedValueOnce(new GeminiError('AI_NOT_CONFIGURED', 'no key')); const fd = new FormData(); fd.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); const res = await POST(mkRequest(fd)); expect(res.status).toBe(503); expect((await res.json()).code).toBe('AI_NOT_CONFIGURED'); }); it('429 when rate limit exceeded for the same IP', async () => { mockExtract.mockResolvedValue(validAiResponse); const fd = () => { const f = new FormData(); f.append('photo', new Blob([Buffer.from('tiny')], { type: 'image/jpeg' })); return f; }; // sharp wird für tiny-buffers werfen — aber rate-limit greift VOR preprocess, // also füllen wir erst 10 OK-Calls auf, dann der 11. sollte 429 sein. // Hack: wir erhöhen die rate-limit-Hürde nicht — wir prüfen nur, dass 429 zurückkommt. for (let i = 0; i < 10; i++) { const jpg = new FormData(); jpg.append('photo', new Blob([await makeJpeg()], { type: 'image/jpeg' })); await POST(mkRequest(jpg, '9.9.9.9')); } const limitHit = await POST(mkRequest(fd(), '9.9.9.9')); expect(limitHit.status).toBe(429); }); }); ``` - [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** ```bash npx vitest run src/routes/api/recipes/extract-from-photo/+server.test.ts ``` Expected: FAIL — Endpoint existiert noch nicht. - [ ] **Step 3: Endpoint implementieren** `src/routes/api/recipes/extract-from-photo/+server.ts`: ```ts import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { extractRecipeFromImage, GeminiError } from '$lib/server/ai/gemini-client'; import { preprocessImage } from '$lib/server/ai/image-preprocess'; import { pickRandomPhrase } from '$lib/server/ai/description-phrases'; import { createRateLimiter } from '$lib/server/ai/rate-limit'; import type { Ingredient, Step } from '$lib/types'; const MAX_BYTES = 8 * 1024 * 1024; const ALLOWED_MIME = new Set([ 'image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif' ]); // Globaler Singleton-Limiter. 10 Requests/Minute pro IP — verhindert versehentliche // Kosten-Runaways (z.B. wenn ein Tester das Icon sturmklickt). const limiter = createRateLimiter({ windowMs: 60_000, max: 10 }); function errJson(status: number, code: string, message: string) { return json({ code, message }, { status }); } function buildRawText(q: number | null, u: string | null, name: string): string { const parts: string[] = []; if (q !== null) parts.push(String(q).replace('.', ',')); if (u) parts.push(u); parts.push(name); return parts.join(' '); } export const POST: RequestHandler = async ({ request, getClientAddress }) => { const ip = getClientAddress(); if (!limiter.check(ip)) { return errJson(429, 'RATE_LIMITED', 'Zu viele Anfragen — bitte einen Moment warten.'); } let form: FormData; try { form = await request.formData(); } catch { return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.'); } const photo = form.get('photo'); if (!(photo instanceof Blob)) { return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.'); } if (photo.size > MAX_BYTES) { return errJson(413, 'PAYLOAD_TOO_LARGE', `Foto zu groß (max ${MAX_BYTES / 1024 / 1024} MB).`); } if (!ALLOWED_MIME.has(photo.type)) { return errJson(415, 'UNSUPPORTED_MEDIA_TYPE', `MIME "${photo.type}" nicht unterstützt.`); } const rawBuffer = Buffer.from(await photo.arrayBuffer()); let preprocessed: { buffer: Buffer; mimeType: 'image/jpeg' }; try { preprocessed = await preprocessImage(rawBuffer); } catch (e) { return errJson(415, 'UNSUPPORTED_MEDIA_TYPE', `Bild konnte nicht gelesen werden: ${(e as Error).message}`); } const startedAt = Date.now(); let extracted; try { extracted = await extractRecipeFromImage(preprocessed.buffer, preprocessed.mimeType); } catch (e) { if (e instanceof GeminiError) { const status = e.code === 'AI_RATE_LIMITED' ? 429 : e.code === 'AI_TIMEOUT' ? 503 : e.code === 'AI_NOT_CONFIGURED' ? 503 : 503; // Nur Code + Meta loggen, niemals den Prompt/Response-Inhalt. console.warn(`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes`); return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.'); } console.warn(`[extract-from-photo] UNEXPECTED ${(e as Error).message}`); return errJson(503, 'AI_FAILED', 'Die Bild-Analyse ist fehlgeschlagen.'); } // Minimum-Gültigkeit: Titel + (mind. 1 Zutat ODER mind. 1 Schritt) if (!extracted.title.trim() || (extracted.ingredients.length === 0 && extracted.steps.length === 0)) { return errJson(422, 'NO_RECIPE_IN_IMAGE', 'Ich konnte kein Rezept im Bild erkennen.'); } const ingredients: Ingredient[] = extracted.ingredients.map((i, idx) => ({ position: idx + 1, quantity: i.quantity, unit: i.unit, name: i.name, note: i.note, raw_text: buildRawText(i.quantity, i.unit, i.name), section_heading: null })); const steps: Step[] = extracted.steps.map((s, idx) => ({ position: idx + 1, text: s.text })); return json({ recipe: { id: null, title: extracted.title, description: pickRandomPhrase(), source_url: null, source_domain: null, image_path: null, servings_default: extracted.servings_default, servings_unit: extracted.servings_unit, prep_time_min: extracted.prep_time_min, cook_time_min: extracted.cook_time_min, total_time_min: extracted.total_time_min, cuisine: null, category: null, ingredients, steps, tags: [] } }); }; ``` - [ ] **Step 4: Tests laufen lassen (erwartet PASS)** ```bash npx vitest run src/routes/api/recipes/extract-from-photo/+server.test.ts ``` Expected: PASS — alle 7 Tests grün. - [ ] **Step 5: Commit** ```bash git add src/routes/api/recipes/extract-from-photo/ git commit -m "feat(api): POST /api/recipes/extract-from-photo" ``` --- ## Task 8: POST /api/recipes (neues Scratch-Insert) **Files:** - Create: `src/routes/api/recipes/+server.ts` - Create: `src/routes/api/recipes/+server.test.ts` Neuer Endpoint, der ein komplettes `Recipe`-Objekt als Body nimmt und via `insertRecipe` schreibt. Wird vom Save-Handler auf `/new/from-photo` aufgerufen. Der bestehende `/api/recipes/blank`-Endpoint bleibt für den klassischen „Leer anlegen"-Button unverändert. - [ ] **Step 1: Failing tests schreiben** `src/routes/api/recipes/+server.test.ts`: ```ts import { describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; import { runMigrations } from '$lib/server/db/migrate'; import { POST } from './+server'; // Hinweis: Wir brauchen eine reale DB. SvelteKit's getDb muss auf eine In-Memory // DB umgeleitet werden — das Muster dafür ist in bestehenden API-Tests // (z.B. src/routes/api/recipes/import/+server.test.ts) zu finden und wird // bei Bedarf adaptiert. function mkReq(body: unknown): Parameters[0] { return { request: new Request('http://test/api/recipes', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) } as unknown as Parameters[0]; } const validBody = { title: 'Aus Foto erstellt', description: 'Abrakadabra — Rezept da.', servings_default: 4, servings_unit: 'Portionen', prep_time_min: 10, cook_time_min: 20, total_time_min: null, ingredients: [ { position: 1, quantity: 1, unit: null, name: 'Apfel', note: null, raw_text: '1 Apfel', section_heading: null } ], steps: [{ position: 1, text: 'Apfel schneiden.' }] }; describe('POST /api/recipes', () => { it('happy path returns 201 + id', async () => { const res = await POST(mkReq(validBody)); expect(res.status).toBe(201); const body = await res.json(); expect(typeof body.id).toBe('number'); expect(body.id).toBeGreaterThan(0); }); it('400 on empty title', async () => { const res = await POST(mkReq({ ...validBody, title: '' })); expect(res.status).toBe(400); }); it('400 on missing ingredients array', async () => { const { ingredients: _, ...bad } = validBody; void _; const res = await POST(mkReq(bad)); expect(res.status).toBe(400); }); }); ``` - [ ] **Step 2: Tests laufen lassen (erwartet FAIL)** ```bash npx vitest run src/routes/api/recipes/+server.test.ts ``` Expected: FAIL — Endpoint existiert noch nicht. - [ ] **Step 3: Endpoint implementieren** `src/routes/api/recipes/+server.ts`: ```ts import type { RequestHandler } from './$types'; import { json } from '@sveltejs/kit'; import { z } from 'zod'; import { getDb } from '$lib/server/db'; import { validateBody } from '$lib/server/api-helpers'; import { insertRecipe } from '$lib/server/recipes/repository'; const IngredientSchema = z.object({ position: z.number().int().nonnegative(), quantity: z.number().nullable(), unit: z.string().max(30).nullable(), name: z.string().min(1).max(200), note: z.string().max(300).nullable(), raw_text: z.string().max(500), section_heading: z.string().max(200).nullable() }); const StepSchema = z.object({ position: z.number().int().positive(), text: z.string().min(1).max(4000) }); const CreateRecipeSchema = z.object({ title: z.string().min(1).max(200), description: z.string().max(2000).nullable(), servings_default: z.number().int().nonnegative().nullable(), servings_unit: z.string().max(30).nullable(), prep_time_min: z.number().int().nonnegative().nullable(), cook_time_min: z.number().int().nonnegative().nullable(), total_time_min: z.number().int().nonnegative().nullable(), ingredients: z.array(IngredientSchema), steps: z.array(StepSchema) }); export const POST: RequestHandler = async ({ request }) => { const body = await request.json().catch(() => null); const p = validateBody(body, CreateRecipeSchema); const id = insertRecipe(getDb(), { id: null, title: p.title, description: p.description, source_url: null, source_domain: null, image_path: null, servings_default: p.servings_default, servings_unit: p.servings_unit, prep_time_min: p.prep_time_min, cook_time_min: p.cook_time_min, total_time_min: p.total_time_min, cuisine: null, category: null, ingredients: p.ingredients, steps: p.steps, tags: [] }); return json({ id }, { status: 201 }); }; ``` - [ ] **Step 4: Tests laufen lassen (erwartet PASS)** ```bash npx vitest run src/routes/api/recipes/+server.test.ts ``` Expected: PASS — alle 3 Tests grün. Falls das DB-Setup im Test nicht klappt: Muster aus bestehenden Tests wie `src/routes/api/recipes/import/+server.test.ts` übernehmen. - [ ] **Step 5: Commit** ```bash git add src/routes/api/recipes/+server.ts src/routes/api/recipes/+server.test.ts git commit -m "feat(api): POST /api/recipes fuer Scratch-Insert aus Foto-Import" ``` --- ## Task 9: RecipeEditor — Nullable-ID-Support **Files:** - Modify: `src/lib/components/RecipeEditor.svelte:136-144` Minimal-Änderung: Bild-Block nur rendern wenn `recipe.id !== null`. Kein State-Management-Umbau nötig. - [ ] **Step 1: Bild-Block conditional wrappen** In `src/lib/components/RecipeEditor.svelte` den Bild-Block von: ```svelte

Bild

onimagechange?.(p)} />
``` ändern zu: ```svelte {#if recipe.id !== null}

Bild

onimagechange?.(p)} />
{:else}

Bild kannst du nach dem Speichern hinzufügen.

{/if} ``` Im ` ``` - [ ] **Step 3: Typecheck** ```bash npm run check ``` Expected: PASS. Falls der `RecipeEditorSavePatch`-Typ nicht auflöst: direkt die Feld-Signatur aus `RecipeEditor.svelte:13-22` kopieren. - [ ] **Step 4: Quick Smoke-Test manuell** ```bash npm run dev ``` Navigiere zu `http://localhost:5173/new/from-photo`. Erwartet: 503-Fehlerseite, weil `GEMINI_API_KEY` leer ist (server-load-Gate greift). Mit Key: `GEMINI_API_KEY=x npm run dev`, dann sollte die Picker-Seite erscheinen. - [ ] **Step 5: Commit** ```bash git add src/routes/new/from-photo/ git commit -m "feat(ui): /new/from-photo mit File-Picker, Lade- und Fehler-States" ``` - [ ] **Step 6: Service-Worker-Precache erweitern** Öffne `src/service-worker.ts` und finde das Pre-Cache-Manifest (suche nach `precache` oder `buildSWManifest`). In der Shell-Liste `'/new/from-photo'` ergänzen, analog zu `/` und `/preview`. ```bash npm run check && npx vitest run ``` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add src/service-worker.ts git commit -m "feat(sw): /new/from-photo in Shell-Precache" ``` --- ## Task 12: Camera-Icon im Header **Files:** - Modify: `src/routes/+layout.svelte` - Modify: `src/routes/+layout.server.ts` (prüfen, ggf. erweitern für `geminiConfigured`-Flag) Der Button wird nur gerendert, wenn der Server signalisiert, dass Gemini konfiguriert ist. Offline → disabled. - [ ] **Step 1: Flag aus Layout-Load exponieren** Öffne `src/routes/+layout.server.ts` (falls nicht vorhanden: neu anlegen): ```ts import type { LayoutServerLoad } from './$types'; import { env } from '$env/dynamic/private'; export const load: LayoutServerLoad = async ({ parent }) => { const parentData = await parent().catch(() => ({})); return { ...parentData, geminiConfigured: Boolean(env.GEMINI_API_KEY) }; }; ``` Falls die Datei bereits existiert und andere Keys liefert: nur `geminiConfigured: Boolean(env.GEMINI_API_KEY)` in das bestehende Return-Objekt ergänzen. - [ ] **Step 2: Icon in den Header** In `src/routes/+layout.svelte` die Header-Zeile erweitern: der Import ergänzt `Camera` zu bestehenden lucide-svelte-Imports, und die Button-Reihe im Header bekommt einen neuen Eintrag. Konkrete Integration: ```svelte {#if data.geminiConfigured} { if (!networkStore.online) e.preventDefault(); }} > {/if} ``` Im `