nca-ai-cms-astro-plugin 1.0.11 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/plans/2026-03-01-image-generator-settings.md +880 -0
- package/package.json +1 -1
- package/src/api/generate-image.ts +3 -2
- package/src/components/editor/SettingsTab.tsx +21 -1
- package/src/db/tables.ts +1 -0
- package/src/index.ts +1 -0
- package/src/middleware.ts +2 -1
- package/src/services/ImageGenerator.test.ts +239 -0
- package/src/services/ImageGenerator.ts +34 -21
- package/src/services/PromptService.test.ts +142 -0
- package/src/services/PromptService.ts +73 -0
- package/src/services/SchedulerDBAdapter.ts +1 -0
- package/update.md +17 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
# ImageGenerator Settings Migration - Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Remove all hardcoded prompt strings from `ImageGenerator.ts` and make every aspect of image generation configurable via the existing Settings system.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The existing `SiteSettings` table (key-value store) and `PromptService` already support arbitrary settings with upsert. We add new `image.*` keys, extend `PromptService` with an `getImageSettings()` method, refactor `ImageGenerator` to read settings at runtime, and add UI fields to the existing `image-ai` sub-tab in `SettingsTab.tsx`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Astro 5 + @astrojs/db (SQLite), React 19, TypeScript, Vitest, Google GenAI (Imagen 4.0 + Gemini 2.0 Flash)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Task 1: Add `getImageSettings()` to PromptService
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `src/services/PromptService.ts:51-83` (after existing `getSetting`/`getAllSettings`)
|
|
17
|
+
- Test: `src/services/PromptService.test.ts` (create)
|
|
18
|
+
|
|
19
|
+
**Step 1: Write the failing test**
|
|
20
|
+
|
|
21
|
+
Create `src/services/PromptService.test.ts`:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
25
|
+
|
|
26
|
+
// We need to mock the astro:db module
|
|
27
|
+
vi.mock('astro:db', () => {
|
|
28
|
+
const rows: Array<{ key: string; value: string }> = [];
|
|
29
|
+
return {
|
|
30
|
+
db: {
|
|
31
|
+
select: vi.fn().mockReturnThis(),
|
|
32
|
+
from: vi.fn().mockReturnThis(),
|
|
33
|
+
where: vi.fn().mockImplementation(() => ({ get: () => rows.find(() => true) })),
|
|
34
|
+
},
|
|
35
|
+
eq: vi.fn(),
|
|
36
|
+
SiteSettings: {},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
import { PromptService } from './PromptService';
|
|
41
|
+
|
|
42
|
+
describe('PromptService.getImageSettings', () => {
|
|
43
|
+
let service: PromptService;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
service = new PromptService();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should have a getImageSettings method', () => {
|
|
50
|
+
expect(typeof service.getImageSettings).toBe('function');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Step 2: Run test to verify it fails**
|
|
56
|
+
|
|
57
|
+
Run: `npx vitest run src/services/PromptService.test.ts --reporter=verbose`
|
|
58
|
+
Expected: FAIL - `getImageSettings` is not a function
|
|
59
|
+
|
|
60
|
+
**Step 3: Define the ImageSettings interface and implement getImageSettings**
|
|
61
|
+
|
|
62
|
+
Add to `src/services/PromptService.ts` after line 1 (imports):
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
export interface ImageSettings {
|
|
66
|
+
baseStylePrompt: string;
|
|
67
|
+
constraints: string;
|
|
68
|
+
sceneTemplate: string;
|
|
69
|
+
altTextTemplate: string;
|
|
70
|
+
filenamePrompt: string;
|
|
71
|
+
categoryScenes: Record<string, string>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const IMAGE_SETTING_KEYS = [
|
|
75
|
+
'image.baseStylePrompt',
|
|
76
|
+
'image.constraints',
|
|
77
|
+
'image.sceneTemplate',
|
|
78
|
+
'image.altTextTemplate',
|
|
79
|
+
'image.filenamePrompt',
|
|
80
|
+
'image.categoryScenes',
|
|
81
|
+
] as const;
|
|
82
|
+
|
|
83
|
+
export const REQUIRED_IMAGE_SETTINGS = [
|
|
84
|
+
'image.baseStylePrompt',
|
|
85
|
+
'image.constraints',
|
|
86
|
+
'image.sceneTemplate',
|
|
87
|
+
'image.altTextTemplate',
|
|
88
|
+
'image.filenamePrompt',
|
|
89
|
+
] as const;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Add method to the `PromptService` class (after `getCoreTags()`):
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
async getImageSettings(): Promise<ImageSettings> {
|
|
96
|
+
const settings: Record<string, string> = {};
|
|
97
|
+
for (const key of IMAGE_SETTING_KEYS) {
|
|
98
|
+
const value = await this.getSetting(key);
|
|
99
|
+
settings[key] = value || '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let categoryScenes: Record<string, string> = {};
|
|
103
|
+
try {
|
|
104
|
+
const raw = settings['image.categoryScenes'];
|
|
105
|
+
if (raw) {
|
|
106
|
+
categoryScenes = JSON.parse(raw);
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
categoryScenes = {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
baseStylePrompt: settings['image.baseStylePrompt'],
|
|
114
|
+
constraints: settings['image.constraints'],
|
|
115
|
+
sceneTemplate: settings['image.sceneTemplate'],
|
|
116
|
+
altTextTemplate: settings['image.altTextTemplate'],
|
|
117
|
+
filenamePrompt: settings['image.filenamePrompt'],
|
|
118
|
+
categoryScenes,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Step 4: Run test to verify it passes**
|
|
124
|
+
|
|
125
|
+
Run: `npx vitest run src/services/PromptService.test.ts --reporter=verbose`
|
|
126
|
+
Expected: PASS
|
|
127
|
+
|
|
128
|
+
**Step 5: Commit**
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git add src/services/PromptService.ts src/services/PromptService.test.ts
|
|
132
|
+
git commit -m "feat: add getImageSettings() to PromptService"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Task 2: Add settings validation helper
|
|
138
|
+
|
|
139
|
+
**Files:**
|
|
140
|
+
- Modify: `src/services/PromptService.ts`
|
|
141
|
+
- Test: `src/services/PromptService.test.ts`
|
|
142
|
+
|
|
143
|
+
**Step 1: Write the failing test**
|
|
144
|
+
|
|
145
|
+
Add to `src/services/PromptService.test.ts`:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { REQUIRED_IMAGE_SETTINGS } from './PromptService';
|
|
149
|
+
|
|
150
|
+
describe('PromptService.validateImageSettings', () => {
|
|
151
|
+
let service: PromptService;
|
|
152
|
+
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
service = new PromptService();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should have a validateImageSettings method', () => {
|
|
158
|
+
expect(typeof service.validateImageSettings).toBe('function');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should return missing fields when settings are empty', () => {
|
|
162
|
+
const settings = {
|
|
163
|
+
baseStylePrompt: '',
|
|
164
|
+
constraints: '',
|
|
165
|
+
sceneTemplate: '',
|
|
166
|
+
altTextTemplate: '',
|
|
167
|
+
filenamePrompt: '',
|
|
168
|
+
categoryScenes: {},
|
|
169
|
+
};
|
|
170
|
+
const result = service.validateImageSettings(settings);
|
|
171
|
+
expect(result.valid).toBe(false);
|
|
172
|
+
expect(result.missing.length).toBe(5);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should return valid when all required fields are filled', () => {
|
|
176
|
+
const settings = {
|
|
177
|
+
baseStylePrompt: 'some style',
|
|
178
|
+
constraints: 'no text',
|
|
179
|
+
sceneTemplate: 'scene about {title}',
|
|
180
|
+
altTextTemplate: 'Image about {title}',
|
|
181
|
+
filenamePrompt: 'filename for {title}',
|
|
182
|
+
categoryScenes: {},
|
|
183
|
+
};
|
|
184
|
+
const result = service.validateImageSettings(settings);
|
|
185
|
+
expect(result.valid).toBe(true);
|
|
186
|
+
expect(result.missing.length).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should detect missing fields by name', () => {
|
|
190
|
+
const settings = {
|
|
191
|
+
baseStylePrompt: 'filled',
|
|
192
|
+
constraints: '',
|
|
193
|
+
sceneTemplate: 'filled {title}',
|
|
194
|
+
altTextTemplate: '',
|
|
195
|
+
filenamePrompt: 'filled {title}',
|
|
196
|
+
categoryScenes: {},
|
|
197
|
+
};
|
|
198
|
+
const result = service.validateImageSettings(settings);
|
|
199
|
+
expect(result.valid).toBe(false);
|
|
200
|
+
expect(result.missing).toContain('constraints');
|
|
201
|
+
expect(result.missing).toContain('altTextTemplate');
|
|
202
|
+
expect(result.missing).not.toContain('baseStylePrompt');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**Step 2: Run test to verify it fails**
|
|
208
|
+
|
|
209
|
+
Run: `npx vitest run src/services/PromptService.test.ts --reporter=verbose`
|
|
210
|
+
Expected: FAIL - `validateImageSettings` is not a function
|
|
211
|
+
|
|
212
|
+
**Step 3: Implement validateImageSettings**
|
|
213
|
+
|
|
214
|
+
Add method to `PromptService` class:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
validateImageSettings(settings: ImageSettings): { valid: boolean; missing: string[] } {
|
|
218
|
+
const requiredFields: Array<keyof Omit<ImageSettings, 'categoryScenes'>> = [
|
|
219
|
+
'baseStylePrompt',
|
|
220
|
+
'constraints',
|
|
221
|
+
'sceneTemplate',
|
|
222
|
+
'altTextTemplate',
|
|
223
|
+
'filenamePrompt',
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const missing = requiredFields.filter(field => !settings[field]?.trim());
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
valid: missing.length === 0,
|
|
230
|
+
missing,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Step 4: Run test to verify it passes**
|
|
236
|
+
|
|
237
|
+
Run: `npx vitest run src/services/PromptService.test.ts --reporter=verbose`
|
|
238
|
+
Expected: PASS
|
|
239
|
+
|
|
240
|
+
**Step 5: Commit**
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
git add src/services/PromptService.ts src/services/PromptService.test.ts
|
|
244
|
+
git commit -m "feat: add validateImageSettings() for required field checks"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Task 3: Refactor ImageGenerator to use settings
|
|
250
|
+
|
|
251
|
+
**Files:**
|
|
252
|
+
- Modify: `src/services/ImageGenerator.ts:1-109`
|
|
253
|
+
- Test: `src/services/ImageGenerator.test.ts` (create)
|
|
254
|
+
|
|
255
|
+
**Step 1: Write the failing test**
|
|
256
|
+
|
|
257
|
+
Create `src/services/ImageGenerator.test.ts`:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
261
|
+
|
|
262
|
+
const mockGetImageSettings = vi.fn();
|
|
263
|
+
const mockValidateImageSettings = vi.fn();
|
|
264
|
+
|
|
265
|
+
vi.mock('./PromptService', () => ({
|
|
266
|
+
PromptService: vi.fn().mockImplementation(() => ({
|
|
267
|
+
getImageSettings: mockGetImageSettings,
|
|
268
|
+
validateImageSettings: mockValidateImageSettings,
|
|
269
|
+
})),
|
|
270
|
+
}));
|
|
271
|
+
|
|
272
|
+
vi.mock('@google/genai', () => ({
|
|
273
|
+
GoogleGenAI: vi.fn().mockImplementation(() => ({
|
|
274
|
+
models: {
|
|
275
|
+
generateImages: vi.fn().mockResolvedValue({
|
|
276
|
+
generatedImages: [{ image: { imageBytes: 'dGVzdA==' } }],
|
|
277
|
+
}),
|
|
278
|
+
},
|
|
279
|
+
})),
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
vi.mock('@google/generative-ai', () => ({
|
|
283
|
+
GoogleGenerativeAI: vi.fn().mockImplementation(() => ({
|
|
284
|
+
getGenerativeModel: vi.fn().mockReturnValue({
|
|
285
|
+
generateContent: vi.fn().mockResolvedValue({
|
|
286
|
+
response: { text: () => 'test-seo-filename' },
|
|
287
|
+
}),
|
|
288
|
+
}),
|
|
289
|
+
})),
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
import { ImageGenerator } from './ImageGenerator';
|
|
293
|
+
|
|
294
|
+
describe('ImageGenerator', () => {
|
|
295
|
+
let generator: ImageGenerator;
|
|
296
|
+
|
|
297
|
+
const validSettings = {
|
|
298
|
+
baseStylePrompt: 'Photorealistic industrial photograph',
|
|
299
|
+
constraints: 'No text, no letters',
|
|
300
|
+
sceneTemplate: 'Scene about "{title}"',
|
|
301
|
+
altTextTemplate: 'Header-Bild zum Thema {title}',
|
|
302
|
+
filenamePrompt: 'Generate filename for "{title}"',
|
|
303
|
+
categoryScenes: { devops: 'server room with blue LEDs' },
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
beforeEach(() => {
|
|
307
|
+
vi.clearAllMocks();
|
|
308
|
+
generator = new ImageGenerator({ apiKey: 'test-key' });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should throw when image settings are not configured', async () => {
|
|
312
|
+
mockGetImageSettings.mockResolvedValue({
|
|
313
|
+
baseStylePrompt: '',
|
|
314
|
+
constraints: '',
|
|
315
|
+
sceneTemplate: '',
|
|
316
|
+
altTextTemplate: '',
|
|
317
|
+
filenamePrompt: '',
|
|
318
|
+
categoryScenes: {},
|
|
319
|
+
});
|
|
320
|
+
mockValidateImageSettings.mockReturnValue({
|
|
321
|
+
valid: false,
|
|
322
|
+
missing: ['baseStylePrompt', 'constraints', 'sceneTemplate', 'altTextTemplate', 'filenamePrompt'],
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await expect(generator.generate('Test Title'))
|
|
326
|
+
.rejects.toThrow(/nicht konfiguriert/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should generate image when all settings are configured', async () => {
|
|
330
|
+
mockGetImageSettings.mockResolvedValue(validSettings);
|
|
331
|
+
mockValidateImageSettings.mockReturnValue({ valid: true, missing: [] });
|
|
332
|
+
|
|
333
|
+
const result = await generator.generate('Test Title');
|
|
334
|
+
expect(result).toHaveProperty('url');
|
|
335
|
+
expect(result).toHaveProperty('alt');
|
|
336
|
+
expect(result).toHaveProperty('filepath');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('should use altTextTemplate from settings with title replaced', async () => {
|
|
340
|
+
mockGetImageSettings.mockResolvedValue(validSettings);
|
|
341
|
+
mockValidateImageSettings.mockReturnValue({ valid: true, missing: [] });
|
|
342
|
+
|
|
343
|
+
const result = await generator.generate('PHP Unit Testing');
|
|
344
|
+
expect(result.alt).toBe('Header-Bild zum Thema PHP Unit Testing');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should include category scene hint when category matches', async () => {
|
|
348
|
+
mockGetImageSettings.mockResolvedValue(validSettings);
|
|
349
|
+
mockValidateImageSettings.mockReturnValue({ valid: true, missing: [] });
|
|
350
|
+
|
|
351
|
+
// We test indirectly - the generate call should succeed with category
|
|
352
|
+
const result = await generator.generate('Docker Basics', 'devops');
|
|
353
|
+
expect(result).toHaveProperty('url');
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**Step 2: Run test to verify it fails**
|
|
359
|
+
|
|
360
|
+
Run: `npx vitest run src/services/ImageGenerator.test.ts --reporter=verbose`
|
|
361
|
+
Expected: FAIL - ImageGenerator doesn't use PromptService yet
|
|
362
|
+
|
|
363
|
+
**Step 3: Refactor ImageGenerator.ts**
|
|
364
|
+
|
|
365
|
+
Replace the entire content of `src/services/ImageGenerator.ts`:
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
import { GoogleGenAI, PersonGeneration } from '@google/genai';
|
|
369
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
370
|
+
import { PromptService, type ImageSettings } from './PromptService';
|
|
371
|
+
import { Slug } from '../domain/value-objects/Slug';
|
|
372
|
+
|
|
373
|
+
export interface GeneratedImage {
|
|
374
|
+
url: string;
|
|
375
|
+
alt: string;
|
|
376
|
+
filepath: string;
|
|
377
|
+
base64?: string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export interface ImageGeneratorConfig {
|
|
381
|
+
apiKey: string;
|
|
382
|
+
model?: string;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export class ImageGenerator {
|
|
386
|
+
private client: GoogleGenAI;
|
|
387
|
+
private textModel: GoogleGenerativeAI;
|
|
388
|
+
private model: string;
|
|
389
|
+
private promptService: PromptService;
|
|
390
|
+
|
|
391
|
+
constructor(config: ImageGeneratorConfig) {
|
|
392
|
+
this.client = new GoogleGenAI({ apiKey: config.apiKey });
|
|
393
|
+
this.textModel = new GoogleGenerativeAI(config.apiKey);
|
|
394
|
+
this.model = config.model || 'imagen-4.0-generate-001';
|
|
395
|
+
this.promptService = new PromptService();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async generate(title: string, category?: string): Promise<GeneratedImage> {
|
|
399
|
+
const settings = await this.promptService.getImageSettings();
|
|
400
|
+
const validation = this.promptService.validateImageSettings(settings);
|
|
401
|
+
|
|
402
|
+
if (!validation.valid) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`Bildgenerierung nicht konfiguriert. Fehlende Settings: ${validation.missing.join(', ')}. ` +
|
|
405
|
+
`Bitte unter Einstellungen \u2192 Bildgenerierung ausf\u00fcllen.`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const prompt = this.buildPrompt(title, settings, category);
|
|
410
|
+
const filename = await this.generateSeoFilename(title, settings);
|
|
411
|
+
const filepath = `dist/client/images/${filename}.webp`;
|
|
412
|
+
|
|
413
|
+
const response = await this.client.models.generateImages({
|
|
414
|
+
model: this.model,
|
|
415
|
+
prompt: prompt,
|
|
416
|
+
config: {
|
|
417
|
+
numberOfImages: 1,
|
|
418
|
+
aspectRatio: '16:9',
|
|
419
|
+
personGeneration: PersonGeneration.DONT_ALLOW,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const base64 = response.generatedImages?.[0]?.image?.imageBytes;
|
|
424
|
+
if (!base64) {
|
|
425
|
+
throw new Error('No image data received from Imagen API');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const alt = this.buildAltText(title, settings);
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
url: `data:image/png;base64,${base64}`,
|
|
432
|
+
alt,
|
|
433
|
+
filepath,
|
|
434
|
+
base64,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private buildPrompt(title: string, settings: ImageSettings, category?: string): string {
|
|
439
|
+
const parts = [
|
|
440
|
+
settings.baseStylePrompt,
|
|
441
|
+
settings.constraints,
|
|
442
|
+
settings.sceneTemplate.replace('{title}', title),
|
|
443
|
+
];
|
|
444
|
+
|
|
445
|
+
const sceneHint = settings.categoryScenes[category || ''] || settings.categoryScenes['default'] || '';
|
|
446
|
+
if (sceneHint) {
|
|
447
|
+
parts.push(`Visual elements: ${sceneHint}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return parts.filter(Boolean).join('. ');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private buildAltText(title: string, settings: ImageSettings): string {
|
|
454
|
+
return settings.altTextTemplate.replace('{title}', title);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async generateSeoFilename(title: string, settings: ImageSettings): Promise<string> {
|
|
458
|
+
try {
|
|
459
|
+
const model = this.textModel.getGenerativeModel({ model: 'gemini-2.0-flash' });
|
|
460
|
+
const prompt = settings.filenamePrompt.replace('{title}', title);
|
|
461
|
+
const result = await model.generateContent(prompt);
|
|
462
|
+
const filename = result.response.text().trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
463
|
+
return filename || Slug.generate(title);
|
|
464
|
+
} catch {
|
|
465
|
+
return Slug.generate(title);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
**Step 4: Run test to verify it passes**
|
|
472
|
+
|
|
473
|
+
Run: `npx vitest run src/services/ImageGenerator.test.ts --reporter=verbose`
|
|
474
|
+
Expected: PASS
|
|
475
|
+
|
|
476
|
+
**Step 5: Commit**
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
git add src/services/ImageGenerator.ts src/services/ImageGenerator.test.ts
|
|
480
|
+
git commit -m "refactor: ImageGenerator reads all prompts from settings, zero hardcoding"
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## Task 4: Update generate-image API to pass category
|
|
486
|
+
|
|
487
|
+
**Files:**
|
|
488
|
+
- Modify: `src/api/generate-image.ts:1-34`
|
|
489
|
+
|
|
490
|
+
**Step 1: Read the current file and understand the schema**
|
|
491
|
+
|
|
492
|
+
Read: `src/api/generate-image.ts`
|
|
493
|
+
|
|
494
|
+
**Step 2: Update the API to accept optional category**
|
|
495
|
+
|
|
496
|
+
Replace content of `src/api/generate-image.ts`:
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
import type { APIRoute } from 'astro';
|
|
500
|
+
import { ImageGenerator } from '../services/ImageGenerator';
|
|
501
|
+
import { z } from 'zod';
|
|
502
|
+
|
|
503
|
+
const schema = z.object({
|
|
504
|
+
input: z.string().min(1),
|
|
505
|
+
category: z.string().optional(),
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
509
|
+
try {
|
|
510
|
+
const body = await request.json();
|
|
511
|
+
const { input, category } = schema.parse(body);
|
|
512
|
+
|
|
513
|
+
const apiKey = import.meta.env.GOOGLE_GEMINI_API_KEY;
|
|
514
|
+
if (!apiKey) {
|
|
515
|
+
return new Response(JSON.stringify({ error: 'GOOGLE_GEMINI_API_KEY not configured' }), { status: 500 });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const generator = new ImageGenerator({ apiKey });
|
|
519
|
+
const image = await generator.generate(input, category);
|
|
520
|
+
|
|
521
|
+
return new Response(JSON.stringify({ url: image.url, alt: image.alt, filepath: image.filepath }), {
|
|
522
|
+
status: 200,
|
|
523
|
+
headers: { 'Content-Type': 'application/json' },
|
|
524
|
+
});
|
|
525
|
+
} catch (error) {
|
|
526
|
+
const message = error instanceof Error ? error.message : 'Image generation failed';
|
|
527
|
+
return new Response(JSON.stringify({ error: message }), { status: 500 });
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
**Step 3: Run existing tests to verify nothing is broken**
|
|
533
|
+
|
|
534
|
+
Run: `npx vitest run --reporter=verbose`
|
|
535
|
+
Expected: All existing tests PASS
|
|
536
|
+
|
|
537
|
+
**Step 4: Commit**
|
|
538
|
+
|
|
539
|
+
```bash
|
|
540
|
+
git add src/api/generate-image.ts
|
|
541
|
+
git commit -m "feat: generate-image API accepts optional category, surfaces settings errors"
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
---
|
|
545
|
+
|
|
546
|
+
## Task 5: Add Image Settings fields to Settings UI
|
|
547
|
+
|
|
548
|
+
**Files:**
|
|
549
|
+
- Modify: `src/components/editor/SettingsTab.tsx:5-32` (add `image-ai` to SETTINGS_FIELDS)
|
|
550
|
+
|
|
551
|
+
**Step 1: Read the current SettingsTab.tsx to understand the exact structure**
|
|
552
|
+
|
|
553
|
+
Read: `src/components/editor/SettingsTab.tsx`
|
|
554
|
+
|
|
555
|
+
**Step 2: Add image settings fields to SETTINGS_FIELDS**
|
|
556
|
+
|
|
557
|
+
In `src/components/editor/SettingsTab.tsx`, extend the `SETTINGS_FIELDS` object to include image-ai fields:
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
const SETTINGS_FIELDS: Record<string, Array<{ key: string; label: string; type: 'text' | 'textarea' | 'json'; placeholder: string }>> = {
|
|
561
|
+
homepage: [
|
|
562
|
+
// ... existing homepage fields unchanged ...
|
|
563
|
+
],
|
|
564
|
+
website: [
|
|
565
|
+
// ... existing website fields unchanged ...
|
|
566
|
+
],
|
|
567
|
+
'image-ai': [
|
|
568
|
+
{
|
|
569
|
+
key: 'image.baseStylePrompt',
|
|
570
|
+
label: 'Bildstil-Prompt',
|
|
571
|
+
type: 'textarea',
|
|
572
|
+
placeholder: 'z.B. Photorealistic industrial photograph, cinematic wide-angle shot, dramatic volumetric lighting...',
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
key: 'image.constraints',
|
|
576
|
+
label: 'Bild-Einschränkungen',
|
|
577
|
+
type: 'textarea',
|
|
578
|
+
placeholder: 'z.B. IMPORTANT: absolutely no text, no letters, no words, no typography anywhere in the image',
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
key: 'image.sceneTemplate',
|
|
582
|
+
label: 'Szenen-Template (mit {title} Platzhalter)',
|
|
583
|
+
type: 'textarea',
|
|
584
|
+
placeholder: 'z.B. Scene visualizing the concept: "{title}"',
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
key: 'image.altTextTemplate',
|
|
588
|
+
label: 'Alt-Text-Template (mit {title} Platzhalter)',
|
|
589
|
+
type: 'text',
|
|
590
|
+
placeholder: 'z.B. Header-Bild zum Thema {title}',
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
key: 'image.filenamePrompt',
|
|
594
|
+
label: 'Dateiname-Prompt (mit {title} Platzhalter)',
|
|
595
|
+
type: 'textarea',
|
|
596
|
+
placeholder: 'z.B. Generate a single SEO-optimized filename for an image about: "{title}"...',
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
key: 'image.categoryScenes',
|
|
600
|
+
label: 'Kategorie-Szenen (JSON)',
|
|
601
|
+
type: 'textarea',
|
|
602
|
+
placeholder: '{"oberflaeche": "precision laser on polished steel", "devops": "server room with blue LED lighting", "default": "modern technology environment"}',
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
};
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
**Step 3: Ensure the image-ai sub-tab renders SETTINGS_FIELDS as form fields (not just prompts)**
|
|
609
|
+
|
|
610
|
+
The current `SettingsTab.tsx` only renders form fields for `homepage` and `website` tabs, and renders prompt cards for `content-ai`, `analysis-ai`, `image-ai`. We need to make `image-ai` render the settings form fields instead of (or in addition to) the prompt cards.
|
|
611
|
+
|
|
612
|
+
In the render logic (around line 269-313 where the settings form is rendered), update the condition to include `image-ai`:
|
|
613
|
+
|
|
614
|
+
Change the condition from:
|
|
615
|
+
```typescript
|
|
616
|
+
{(activeSubTab === 'homepage' || activeSubTab === 'website') && (
|
|
617
|
+
```
|
|
618
|
+
to:
|
|
619
|
+
```typescript
|
|
620
|
+
{(activeSubTab === 'homepage' || activeSubTab === 'website' || activeSubTab === 'image-ai') && (
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
**Note:** Keep the existing prompt cards section for `image-ai` as well, so users can manage both the settings fields AND any custom prompts. The settings form should appear above the prompt cards.
|
|
624
|
+
|
|
625
|
+
**Step 4: Verify UI renders correctly**
|
|
626
|
+
|
|
627
|
+
Start dev server and navigate to Settings > Image AI tab. Verify:
|
|
628
|
+
- All 6 fields appear with correct labels and placeholders
|
|
629
|
+
- Saving writes values to the database
|
|
630
|
+
- Reloading shows saved values
|
|
631
|
+
|
|
632
|
+
**Step 5: Commit**
|
|
633
|
+
|
|
634
|
+
```bash
|
|
635
|
+
git add src/components/editor/SettingsTab.tsx
|
|
636
|
+
git commit -m "feat: add image generation settings fields to image-ai tab"
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
## Task 6: Add settings loading to SettingsTab for image-ai keys
|
|
642
|
+
|
|
643
|
+
**Files:**
|
|
644
|
+
- Modify: `src/components/editor/SettingsTab.tsx` (ensure image.* keys are loaded into settingsForm state)
|
|
645
|
+
|
|
646
|
+
**Step 1: Verify settings loading logic**
|
|
647
|
+
|
|
648
|
+
Read `src/components/editor/SettingsTab.tsx` around the `useEffect` that loads settings (around line 95). The current `loadData` function fetches all settings and prompts. Verify that:
|
|
649
|
+
- `GET /api/prompts` already returns all settings from the DB
|
|
650
|
+
- The `settingsForm` state is populated from the response
|
|
651
|
+
|
|
652
|
+
**Step 2: Ensure settingsForm initialization includes image.* keys**
|
|
653
|
+
|
|
654
|
+
The existing logic should already work if it iterates over all settings returned by the API. Check if the settings form is populated by iterating over `SETTINGS_FIELDS[activeSubTab]` or all settings. If it only populates known keys, extend it.
|
|
655
|
+
|
|
656
|
+
In the `loadData` function, ensure the settings form is populated from all settings returned:
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
const settingsMap: Record<string, string> = {};
|
|
660
|
+
for (const setting of data.settings) {
|
|
661
|
+
settingsMap[setting.key] = setting.value;
|
|
662
|
+
}
|
|
663
|
+
setSettingsForm(settingsMap);
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
If the current code already does this, no change is needed. If it only populates specific keys, update it to be generic.
|
|
667
|
+
|
|
668
|
+
**Step 3: Verify save logic handles image.* keys**
|
|
669
|
+
|
|
670
|
+
The save handler should already work since it uses the generic `type: 'setting'` POST to `/api/prompts`. Verify the save button iterates over all fields in `SETTINGS_FIELDS[activeSubTab]`.
|
|
671
|
+
|
|
672
|
+
**Step 4: Test end-to-end**
|
|
673
|
+
|
|
674
|
+
1. Open Settings > Image AI
|
|
675
|
+
2. Fill in all fields
|
|
676
|
+
3. Click Save
|
|
677
|
+
4. Reload page
|
|
678
|
+
5. Verify values persist
|
|
679
|
+
|
|
680
|
+
**Step 5: Commit (if changes were needed)**
|
|
681
|
+
|
|
682
|
+
```bash
|
|
683
|
+
git add src/components/editor/SettingsTab.tsx
|
|
684
|
+
git commit -m "fix: ensure image settings are loaded and saved correctly"
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
---
|
|
688
|
+
|
|
689
|
+
## Task 7: Add validation for {title} placeholder in templates
|
|
690
|
+
|
|
691
|
+
**Files:**
|
|
692
|
+
- Modify: `src/components/editor/SettingsTab.tsx` (add client-side validation)
|
|
693
|
+
|
|
694
|
+
**Step 1: Add validation before save**
|
|
695
|
+
|
|
696
|
+
In the save handler for settings, add validation for image-ai fields that require `{title}`:
|
|
697
|
+
|
|
698
|
+
```typescript
|
|
699
|
+
// Before saving image-ai settings, validate {title} placeholder
|
|
700
|
+
if (activeSubTab === 'image-ai') {
|
|
701
|
+
const templateFields = ['image.sceneTemplate', 'image.altTextTemplate', 'image.filenamePrompt'];
|
|
702
|
+
for (const key of templateFields) {
|
|
703
|
+
const value = settingsForm[key];
|
|
704
|
+
if (value && !value.includes('{title}')) {
|
|
705
|
+
alert(`Das Feld "${key}" muss den Platzhalter {title} enthalten.`);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
**Step 2: Test validation**
|
|
713
|
+
|
|
714
|
+
1. Open Settings > Image AI
|
|
715
|
+
2. Enter a scene template without `{title}`
|
|
716
|
+
3. Click Save
|
|
717
|
+
4. Verify alert appears and save is blocked
|
|
718
|
+
|
|
719
|
+
**Step 3: Commit**
|
|
720
|
+
|
|
721
|
+
```bash
|
|
722
|
+
git add src/components/editor/SettingsTab.tsx
|
|
723
|
+
git commit -m "feat: validate {title} placeholder in image template settings"
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
---
|
|
727
|
+
|
|
728
|
+
## Task 8: Integration test - full flow
|
|
729
|
+
|
|
730
|
+
**Files:**
|
|
731
|
+
- Test: `src/services/ImageGenerator.test.ts` (extend)
|
|
732
|
+
|
|
733
|
+
**Step 1: Add integration-style test for buildPrompt composition**
|
|
734
|
+
|
|
735
|
+
Add to `src/services/ImageGenerator.test.ts`:
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
describe('ImageGenerator prompt composition', () => {
|
|
739
|
+
it('should compose prompt from all settings parts', async () => {
|
|
740
|
+
const settingsWithCategory = {
|
|
741
|
+
baseStylePrompt: 'Photorealistic',
|
|
742
|
+
constraints: 'No text',
|
|
743
|
+
sceneTemplate: 'Scene about "{title}"',
|
|
744
|
+
altTextTemplate: 'Bild: {title}',
|
|
745
|
+
filenamePrompt: 'Filename for "{title}"',
|
|
746
|
+
categoryScenes: { devops: 'server room', default: 'tech office' },
|
|
747
|
+
};
|
|
748
|
+
mockGetImageSettings.mockResolvedValue(settingsWithCategory);
|
|
749
|
+
mockValidateImageSettings.mockReturnValue({ valid: true, missing: [] });
|
|
750
|
+
|
|
751
|
+
// Generate with category - should include category scene hint
|
|
752
|
+
const result = await generator.generate('CI/CD Pipeline', 'devops');
|
|
753
|
+
expect(result.alt).toBe('Bild: CI/CD Pipeline');
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('should use default category scene when category not found', async () => {
|
|
757
|
+
const settings = {
|
|
758
|
+
baseStylePrompt: 'Style',
|
|
759
|
+
constraints: 'Constraints',
|
|
760
|
+
sceneTemplate: 'Scene "{title}"',
|
|
761
|
+
altTextTemplate: 'Alt {title}',
|
|
762
|
+
filenamePrompt: 'File {title}',
|
|
763
|
+
categoryScenes: { default: 'generic tech' },
|
|
764
|
+
};
|
|
765
|
+
mockGetImageSettings.mockResolvedValue(settings);
|
|
766
|
+
mockValidateImageSettings.mockReturnValue({ valid: true, missing: [] });
|
|
767
|
+
|
|
768
|
+
const result = await generator.generate('Unknown Category Article', 'nonexistent');
|
|
769
|
+
expect(result).toHaveProperty('url');
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it('should list missing fields in error message', async () => {
|
|
773
|
+
mockGetImageSettings.mockResolvedValue({
|
|
774
|
+
baseStylePrompt: 'filled',
|
|
775
|
+
constraints: '',
|
|
776
|
+
sceneTemplate: '',
|
|
777
|
+
altTextTemplate: 'filled {title}',
|
|
778
|
+
filenamePrompt: '',
|
|
779
|
+
categoryScenes: {},
|
|
780
|
+
});
|
|
781
|
+
mockValidateImageSettings.mockReturnValue({
|
|
782
|
+
valid: false,
|
|
783
|
+
missing: ['constraints', 'sceneTemplate', 'filenamePrompt'],
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
await expect(generator.generate('Test'))
|
|
787
|
+
.rejects.toThrow('constraints, sceneTemplate, filenamePrompt');
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
**Step 2: Run all tests**
|
|
793
|
+
|
|
794
|
+
Run: `npx vitest run --reporter=verbose`
|
|
795
|
+
Expected: ALL PASS
|
|
796
|
+
|
|
797
|
+
**Step 3: Commit**
|
|
798
|
+
|
|
799
|
+
```bash
|
|
800
|
+
git add src/services/ImageGenerator.test.ts
|
|
801
|
+
git commit -m "test: add integration tests for ImageGenerator settings composition"
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
---
|
|
805
|
+
|
|
806
|
+
## Task 9: Verify zero hardcoding with grep
|
|
807
|
+
|
|
808
|
+
**Files:** None (verification only)
|
|
809
|
+
|
|
810
|
+
**Step 1: Grep for hardcoded strings in ImageGenerator**
|
|
811
|
+
|
|
812
|
+
Run: `grep -n "Sheeler\|Precisionism\|accessibility\|Barrierefreiheit\|geometric shapes\|sharp focus" src/services/ImageGenerator.ts`
|
|
813
|
+
Expected: No matches
|
|
814
|
+
|
|
815
|
+
**Step 2: Grep for any remaining hardcoded prompt content**
|
|
816
|
+
|
|
817
|
+
Run: `grep -n "Blog header image\|Minimal Precisionism\|Illustration zum Thema" src/services/ImageGenerator.ts`
|
|
818
|
+
Expected: No matches
|
|
819
|
+
|
|
820
|
+
**Step 3: Verify only dynamic/structural strings remain**
|
|
821
|
+
|
|
822
|
+
Run: `grep -n "'" src/services/ImageGenerator.ts | head -30`
|
|
823
|
+
Expected: Only structural strings like `'data:image/png;base64,'`, `'{title}'`, `'Visual elements: '`, error messages
|
|
824
|
+
|
|
825
|
+
**Step 4: Commit (verification step, no code changes)**
|
|
826
|
+
|
|
827
|
+
No commit needed - this is a verification step.
|
|
828
|
+
|
|
829
|
+
---
|
|
830
|
+
|
|
831
|
+
## Task 10: Final test run and cleanup
|
|
832
|
+
|
|
833
|
+
**Files:** All modified files
|
|
834
|
+
|
|
835
|
+
**Step 1: Run full test suite**
|
|
836
|
+
|
|
837
|
+
Run: `npx vitest run --reporter=verbose`
|
|
838
|
+
Expected: ALL tests PASS
|
|
839
|
+
|
|
840
|
+
**Step 2: Run TypeScript type check**
|
|
841
|
+
|
|
842
|
+
Run: `npx tsc --noEmit`
|
|
843
|
+
Expected: No type errors
|
|
844
|
+
|
|
845
|
+
**Step 3: Final commit with all remaining changes**
|
|
846
|
+
|
|
847
|
+
```bash
|
|
848
|
+
git status
|
|
849
|
+
# If any unstaged changes remain:
|
|
850
|
+
git add -A
|
|
851
|
+
git commit -m "chore: final cleanup for image settings migration"
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
---
|
|
855
|
+
|
|
856
|
+
## File Change Summary
|
|
857
|
+
|
|
858
|
+
| File | Action | Description |
|
|
859
|
+
|------|--------|-------------|
|
|
860
|
+
| `src/services/PromptService.ts` | Modify | Add `ImageSettings` interface, `getImageSettings()`, `validateImageSettings()` |
|
|
861
|
+
| `src/services/PromptService.test.ts` | Create | Tests for new methods |
|
|
862
|
+
| `src/services/ImageGenerator.ts` | Rewrite | Remove all hardcoding, use PromptService for settings |
|
|
863
|
+
| `src/services/ImageGenerator.test.ts` | Create | Full test coverage with mocked settings |
|
|
864
|
+
| `src/api/generate-image.ts` | Modify | Accept optional `category`, surface settings errors |
|
|
865
|
+
| `src/components/editor/SettingsTab.tsx` | Modify | Add image-ai settings form fields, validation |
|
|
866
|
+
|
|
867
|
+
## Dependencies Between Tasks
|
|
868
|
+
|
|
869
|
+
```
|
|
870
|
+
Task 1 (PromptService.getImageSettings)
|
|
871
|
+
└── Task 2 (validateImageSettings)
|
|
872
|
+
└── Task 3 (Refactor ImageGenerator) ← core change
|
|
873
|
+
└── Task 4 (Update API)
|
|
874
|
+
Task 5 (Settings UI fields) ← independent, can parallel with 3-4
|
|
875
|
+
└── Task 6 (Settings loading)
|
|
876
|
+
└── Task 7 (Validation)
|
|
877
|
+
Task 8 (Integration tests) ← after 3
|
|
878
|
+
Task 9 (Grep verification) ← after 3
|
|
879
|
+
Task 10 (Final check) ← after all
|
|
880
|
+
```
|