feat(picture): PWA support, API timeouts, batch fix, credit/history endpoints

PWA:
- Switch to createOfflineFirstPWAConfig for offline-first PWA support
- Configure with Picture branding (purple theme, German metadata)

Replicate API timeouts:
- Add AbortController-based timeouts to all 7 fetch calls
- Create prediction: 30s, poll status: 60s, cancel: 10s, image fetch: 30s
- Polling timeouts are non-fatal (logged as warning, retry continues)

Batch service fix:
- Join images table via leftJoin to return actual imageUrl
- Previously always returned null (TODO comment)

New endpoints:
- GET /api/v1/generate/credits — returns credit balance, earned, spent
- GET /api/v1/generate/history — paginated generation history with images

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-21 21:17:44 +01:00
parent 512627b32a
commit b735f146bf
5 changed files with 225 additions and 39 deletions

View file

@ -5,6 +5,7 @@ import { Database } from '../db/connection';
import {
batchGenerations,
imageGenerations,
images,
type BatchGeneration,
type NewBatchGeneration,
} from '../db/schema';
@ -114,7 +115,7 @@ export class BatchService {
throw new ForbiddenException('Access denied');
}
// Get items
// Get items with their associated image URLs
const items = await this.db
.select({
id: imageGenerations.id,
@ -123,8 +124,10 @@ export class BatchService {
errorMessage: imageGenerations.errorMessage,
retryCount: imageGenerations.retryCount,
priority: imageGenerations.priority,
imageUrl: images.publicUrl,
})
.from(imageGenerations)
.leftJoin(images, eq(images.generationId, imageGenerations.id))
.where(eq(imageGenerations.batchId, batchId))
.orderBy(imageGenerations.priority);
@ -137,7 +140,7 @@ export class BatchService {
status: item.status,
errorMessage: item.errorMessage,
retryCount: item.retryCount ?? 0,
imageUrl: null, // TODO: Join with images table to get URL
imageUrl: item.imageUrl ?? null,
})),
};
} catch (error) {

View file

@ -5,6 +5,7 @@ import {
Delete,
Param,
Body,
Query,
Headers,
UseGuards,
UnauthorizedException,
@ -33,6 +34,25 @@ export class GenerateController {
return this.generateService.generateImage(user.userId, dto);
}
@Get('credits')
@UseGuards(JwtAuthGuard)
async getCreditBalance(@CurrentUser() user: CurrentUserData) {
return this.generateService.getCreditBalance(user.userId);
}
@Get('history')
@UseGuards(JwtAuthGuard)
async getGenerationHistory(
@CurrentUser() user: CurrentUserData,
@Query('page') page?: string,
@Query('limit') limit?: string
) {
const pageNum = Math.max(1, parseInt(page || '1', 10) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit || '20', 10) || 20));
return this.generateService.getGenerationHistory(user.userId, pageNum, limitNum);
}
@Get(':id/status')
@UseGuards(JwtAuthGuard)
async checkStatus(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {

View file

@ -8,7 +8,7 @@ import {
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { eq, desc, sql } from 'drizzle-orm';
import { CreditClientService } from '@manacore/nestjs-integration';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
@ -484,6 +484,96 @@ export class GenerateService {
}
}
/**
* Get user's credit balance
*/
async getCreditBalance(userId: string): Promise<{
balance: number;
totalEarned: number;
totalSpent: number;
creditsPerGeneration: number;
}> {
const creditBalance = await this.creditClient.getBalance(userId);
return {
balance: creditBalance.balance,
totalEarned: creditBalance.totalEarned,
totalSpent: creditBalance.totalSpent,
creditsPerGeneration: CREDITS_PER_GENERATION,
};
}
/**
* Get user's generation history with associated images, paginated
*/
async getGenerationHistory(
userId: string,
page: number,
limit: number
): Promise<{
data: (ImageGeneration & { image?: Image })[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
const offset = (page - 1) * limit;
// Get total count
const countResult = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(imageGenerations)
.where(eq(imageGenerations.userId, userId));
const total = countResult[0]?.count ?? 0;
// Get paginated generations
const generations = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.userId, userId))
.orderBy(desc(imageGenerations.createdAt))
.limit(limit)
.offset(offset);
// Fetch associated images for completed generations
const generationIds = generations.filter((g) => g.status === 'completed').map((g) => g.id);
let imageMap = new Map<string, Image>();
if (generationIds.length > 0) {
const generationImages = await this.db
.select()
.from(images)
.where(
sql`${images.generationId} IN (${sql.join(
generationIds.map((id) => sql`${id}`),
sql`, `
)})`
);
for (const img of generationImages) {
if (img.generationId) {
imageMap.set(img.generationId, img);
}
}
}
// Merge images into generations
const data = generations.map((generation) => ({
...generation,
image: imageMap.get(generation.id),
}));
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async handleWebhook(body: any): Promise<{ received: boolean }> {
try {
const { id, status, output, error } = body;

View file

@ -43,6 +43,15 @@ export class ReplicateService {
private replicate: Replicate | null = null;
private readonly apiToken: string | undefined;
/** Timeout for creating predictions (POST) */
private readonly CREATE_TIMEOUT_MS = 30_000;
/** Timeout for polling prediction status (GET) */
private readonly POLL_TIMEOUT_MS = 60_000;
/** Timeout for canceling predictions (POST) */
private readonly CANCEL_TIMEOUT_MS = 10_000;
/** Timeout for fetching source images for img2img */
private readonly IMAGE_FETCH_TIMEOUT_MS = 30_000;
constructor(private configService: ConfigService) {
this.apiToken = this.configService.get<string>('REPLICATE_API_TOKEN');
if (this.apiToken) {
@ -52,6 +61,37 @@ export class ReplicateService {
}
}
/**
* Execute a fetch request with an AbortController timeout.
* Throws a descriptive error on timeout instead of hanging indefinitely.
*/
private async fetchWithTimeout(
url: string,
options: RequestInit,
timeoutMs: number,
operationName: string
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (error: any) {
if (error.name === 'AbortError') {
throw new Error(
`Replicate API timeout: ${operationName} did not complete within ${timeoutMs / 1000}s`
);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Calculate greatest common divisor for aspect ratio simplification
*/
@ -75,7 +115,12 @@ export class ReplicateService {
private async convertImageToBase64(imageUrl: string): Promise<string> {
this.logger.debug(`Converting image to base64: ${imageUrl}`);
const imageResponse = await fetch(imageUrl);
const imageResponse = await this.fetchWithTimeout(
imageUrl,
{},
this.IMAGE_FETCH_TIMEOUT_MS,
'fetch source image'
);
if (!imageResponse.ok) {
throw new Error('Failed to fetch source image');
}
@ -447,14 +492,19 @@ export class ReplicateService {
// Call Replicate API to start prediction
this.logger.log('Calling Replicate API...');
const replicateResponse = await fetch('https://api.replicate.com/v1/predictions', {
method: 'POST',
headers: {
Authorization: `Token ${this.apiToken}`,
'Content-Type': 'application/json',
const replicateResponse = await this.fetchWithTimeout(
'https://api.replicate.com/v1/predictions',
{
method: 'POST',
headers: {
Authorization: `Token ${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
body: JSON.stringify(requestBody),
});
this.CREATE_TIMEOUT_MS,
'create prediction'
);
if (!replicateResponse.ok) {
const errorText = await replicateResponse.text();
@ -473,14 +523,22 @@ export class ReplicateService {
await new Promise((resolve) => setTimeout(resolve, 5000)); // Poll every 5 seconds
attempts++;
const statusResponse = await fetch(
`https://api.replicate.com/v1/predictions/${prediction.id}`,
{
headers: {
Authorization: `Token ${this.apiToken}`,
let statusResponse: Response;
try {
statusResponse = await this.fetchWithTimeout(
`https://api.replicate.com/v1/predictions/${prediction.id}`,
{
headers: {
Authorization: `Token ${this.apiToken}`,
},
},
}
);
this.POLL_TIMEOUT_MS,
`poll prediction ${prediction.id}`
);
} catch (pollError: any) {
this.logger.warn(`Poll attempt ${attempts} failed: ${pollError.message}`);
continue; // Retry on next interval
}
if (!statusResponse.ok) {
this.logger.warn('Failed to get prediction status');
@ -566,14 +624,19 @@ export class ReplicateService {
requestBody.model = modelId;
}
const response = await fetch('https://api.replicate.com/v1/predictions', {
method: 'POST',
headers: {
Authorization: `Token ${this.apiToken}`,
'Content-Type': 'application/json',
const response = await this.fetchWithTimeout(
'https://api.replicate.com/v1/predictions',
{
method: 'POST',
headers: {
Authorization: `Token ${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
body: JSON.stringify(requestBody),
});
this.CREATE_TIMEOUT_MS,
'create prediction (webhook mode)'
);
if (!response.ok) {
const errorText = await response.text();
@ -600,11 +663,16 @@ export class ReplicateService {
}
try {
const response = await fetch(`https://api.replicate.com/v1/predictions/${predictionId}`, {
headers: {
Authorization: `Token ${this.apiToken}`,
const response = await this.fetchWithTimeout(
`https://api.replicate.com/v1/predictions/${predictionId}`,
{
headers: {
Authorization: `Token ${this.apiToken}`,
},
},
});
this.POLL_TIMEOUT_MS,
`get prediction ${predictionId}`
);
if (!response.ok) {
throw new Error(`Failed to get prediction: ${response.status}`);
@ -631,12 +699,17 @@ export class ReplicateService {
}
try {
await fetch(`https://api.replicate.com/v1/predictions/${predictionId}/cancel`, {
method: 'POST',
headers: {
Authorization: `Token ${this.apiToken}`,
await this.fetchWithTimeout(
`https://api.replicate.com/v1/predictions/${predictionId}/cancel`,
{
method: 'POST',
headers: {
Authorization: `Token ${this.apiToken}`,
},
},
});
this.CANCEL_TIMEOUT_MS,
`cancel prediction ${predictionId}`
);
} catch (error) {
this.logger.error(`Error canceling prediction ${predictionId}`, error);
throw error;

View file

@ -2,7 +2,7 @@ import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { createPWAConfig } from '@manacore/shared-pwa';
import { createOfflineFirstPWAConfig } from '@manacore/shared-pwa';
import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite-config';
export default defineConfig({
@ -10,11 +10,11 @@ export default defineConfig({
tailwindcss() as any,
sveltekit() as any,
SvelteKitPWA(
createPWAConfig({
name: 'Picture - KI Bildgenerator',
createOfflineFirstPWAConfig({
name: 'Picture - AI Bildgenerierung',
shortName: 'Picture',
description: 'KI-gestützte Bildgenerierung',
themeColor: '#ec4899',
description: 'KI-gestützte Bildgenerierung und -verwaltung',
themeColor: '#8b5cf6',
})
) as any,
],