diff --git a/apps/picture/apps/backend/src/batch/batch.service.ts b/apps/picture/apps/backend/src/batch/batch.service.ts index eaf4f171e..712649567 100644 --- a/apps/picture/apps/backend/src/batch/batch.service.ts +++ b/apps/picture/apps/backend/src/batch/batch.service.ts @@ -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) { diff --git a/apps/picture/apps/backend/src/generate/generate.controller.ts b/apps/picture/apps/backend/src/generate/generate.controller.ts index 04c950047..2b90de8e8 100644 --- a/apps/picture/apps/backend/src/generate/generate.controller.ts +++ b/apps/picture/apps/backend/src/generate/generate.controller.ts @@ -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) { diff --git a/apps/picture/apps/backend/src/generate/generate.service.ts b/apps/picture/apps/backend/src/generate/generate.service.ts index 6a821b2c7..8e498e8a2 100644 --- a/apps/picture/apps/backend/src/generate/generate.service.ts +++ b/apps/picture/apps/backend/src/generate/generate.service.ts @@ -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`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(); + + 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; diff --git a/apps/picture/apps/backend/src/generate/replicate.service.ts b/apps/picture/apps/backend/src/generate/replicate.service.ts index 0ce649784..a2f86c4f4 100644 --- a/apps/picture/apps/backend/src/generate/replicate.service.ts +++ b/apps/picture/apps/backend/src/generate/replicate.service.ts @@ -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('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 { + 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 { 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; diff --git a/apps/picture/apps/web/vite.config.ts b/apps/picture/apps/web/vite.config.ts index f830d5535..dd291c7cf 100644 --- a/apps/picture/apps/web/vite.config.ts +++ b/apps/picture/apps/web/vite.config.ts @@ -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, ],