mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 02:59:40 +02:00
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:
parent
512627b32a
commit
b735f146bf
5 changed files with 225 additions and 39 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue