diff --git a/apps/api-service/src/routes/v1/generations.ts b/apps/api-service/src/routes/v1/generations.ts index 9a862bd..e0dc205 100644 --- a/apps/api-service/src/routes/v1/generations.ts +++ b/apps/api-service/src/routes/v1/generations.ts @@ -41,8 +41,8 @@ generationsRouter.post( referenceImages, aspectRatio, flowId, - assignAlias, - assignFlowAlias, + alias, + flowAlias, autoEnhance, meta, } = req.body; @@ -68,8 +68,8 @@ generationsRouter.post( referenceImages, aspectRatio, flowId, - assignAlias, - assignFlowAlias, + alias, + flowAlias, autoEnhance, meta, requestId: req.requestId, @@ -158,6 +158,58 @@ generationsRouter.get( }) ); +/** + * PUT /api/v1/generations/:id + * Update generation parameters (prompt, aspectRatio, flowId, meta) + * Generative parameters (prompt, aspectRatio) trigger automatic regeneration + */ +generationsRouter.put( + '/:id', + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + asyncHandler(async (req: any, res: Response) => { + const service = getGenerationService(); + const { id } = req.params; + const { prompt, aspectRatio, flowId, meta } = req.body; + + const original = await service.getById(id); + if (!original) { + res.status(404).json({ + success: false, + error: { + message: 'Generation not found', + code: 'GENERATION_NOT_FOUND', + }, + }); + return; + } + + if (original.projectId !== req.apiKey.projectId) { + res.status(404).json({ + success: false, + error: { + message: 'Generation not found', + code: 'GENERATION_NOT_FOUND', + }, + }); + return; + } + + const updated = await service.update(id, { + prompt, + aspectRatio, + flowId, + meta, + }); + + res.json({ + success: true, + data: toGenerationResponse(updated), + }); + }) +); + /** * POST /api/v1/generations/:id/retry * Retry a failed generation diff --git a/apps/api-service/src/services/core/GenerationService.ts b/apps/api-service/src/services/core/GenerationService.ts index 3fe5ad2..a8b049b 100644 --- a/apps/api-service/src/services/core/GenerationService.ts +++ b/apps/api-service/src/services/core/GenerationService.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'crypto'; import { eq, desc, count } from 'drizzle-orm'; import { db } from '@/db'; import { generations, flows } from '@banatie/database'; @@ -13,6 +14,7 @@ import { ImageGenService } from '../ImageGenService'; import { StorageFactory } from '../StorageFactory'; import { buildWhereClause, buildEqCondition } from '@/utils/helpers'; import { ERROR_MESSAGES, GENERATION_LIMITS } from '@/utils/constants'; +import { extractAliasesFromPrompt } from '@/utils/validators'; import type { ReferenceImage } from '@/types/api'; export interface CreateGenerationParams { @@ -22,8 +24,8 @@ export interface CreateGenerationParams { referenceImages?: string[] | undefined; // Aliases to resolve aspectRatio?: string | undefined; flowId?: string | undefined; - assignAlias?: string | undefined; - assignFlowAlias?: Record | undefined; + alias?: string | undefined; + flowAlias?: string | undefined; autoEnhance?: boolean | undefined; enhancedPrompt?: string | undefined; meta?: Record | undefined; @@ -49,9 +51,29 @@ export class GenerationService { async create(params: CreateGenerationParams): Promise { const startTime = Date.now(); + // Auto-detect aliases from prompt and merge with manual references + const autoDetectedAliases = extractAliasesFromPrompt(params.prompt); + const manualReferences = params.referenceImages || []; + + // Merge: manual references first, then auto-detected (remove duplicates) + const allReferences = Array.from(new Set([...manualReferences, ...autoDetectedAliases])); + + // FlowId logic (Section 10.1): + // - If undefined (not provided) → generate new UUID + // - If null (explicitly null) → keep null + // - If string (specific value) → use that value + let finalFlowId: string | null; + if (params.flowId === undefined) { + finalFlowId = randomUUID(); + } else if (params.flowId === null) { + finalFlowId = null; + } else { + finalFlowId = params.flowId; + } + const generationRecord: NewGeneration = { projectId: params.projectId, - flowId: params.flowId || null, + flowId: finalFlowId, apiKeyId: params.apiKeyId, status: 'pending', originalPrompt: params.prompt, @@ -77,9 +99,9 @@ export class GenerationService { let referenceImageBuffers: ReferenceImage[] = []; let referencedImagesMetadata: Array<{ imageId: string; alias: string }> = []; - if (params.referenceImages && params.referenceImages.length > 0) { + if (allReferences.length > 0) { const resolved = await this.resolveReferenceImages( - params.referenceImages, + allReferences, params.projectId, params.flowId ); @@ -117,7 +139,7 @@ export class GenerationService { const imageRecord = await this.imageService.create({ projectId: params.projectId, - flowId: params.flowId || null, + flowId: finalFlowId, generationId: generation.id, apiKeyId: params.apiKeyId, storageKey, @@ -126,19 +148,34 @@ export class GenerationService { fileSize: genResult.size || 0, fileHash, source: 'generated', - alias: params.assignAlias || null, + alias: params.alias || null, meta: params.meta || {}, }); - if (params.assignFlowAlias && params.flowId) { - await this.assignFlowAliases(params.flowId, params.assignFlowAlias, imageRecord.id); + // Eager flow creation if flowAlias is provided (Section 4.2) + if (params.flowAlias && finalFlowId) { + // Check if flow exists, create if not + const existingFlow = await db.query.flows.findFirst({ + where: eq(flows.id, finalFlowId), + }); + + if (!existingFlow) { + await db.insert(flows).values({ + id: finalFlowId, + projectId: params.projectId, + aliases: {}, + meta: {}, + }); + } + + await this.assignFlowAlias(finalFlowId, params.flowAlias, imageRecord.id); } - if (params.flowId) { + if (finalFlowId) { await db .update(flows) .set({ updatedAt: new Date() }) - .where(eq(flows.id, params.flowId)); + .where(eq(flows.id, finalFlowId)); } const processingTime = Date.now() - startTime; @@ -210,9 +247,9 @@ export class GenerationService { return { buffers, metadata }; } - private async assignFlowAliases( + private async assignFlowAlias( flowId: string, - flowAliases: Record, + flowAlias: string, imageId: string ): Promise { const flow = await db.query.flows.findFirst({ @@ -226,11 +263,8 @@ export class GenerationService { const currentAliases = (flow.aliases as Record) || {}; const updatedAliases = { ...currentAliases }; - for (const [alias, value] of Object.entries(flowAliases)) { - if (value === '@output' || value === imageId) { - updatedAliases[alias] = imageId; - } - } + // Assign the flow alias to the image + updatedAliases[flowAlias] = imageId; await db .update(flows) @@ -364,6 +398,107 @@ export class GenerationService { return newGeneration; } + async update( + id: string, + updates: { + prompt?: string; + aspectRatio?: string; + flowId?: string | null; + meta?: Record; + } + ): Promise { + const generation = await this.getById(id); + if (!generation) { + throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND); + } + + // Check if generative parameters changed (prompt or aspectRatio) + const shouldRegenerate = + (updates.prompt !== undefined && updates.prompt !== generation.originalPrompt) || + (updates.aspectRatio !== undefined && updates.aspectRatio !== generation.aspectRatio); + + // Handle flowId change (Section 9.2) + if (updates.flowId !== undefined && updates.flowId !== null) { + // If flowId provided and not null, create flow if it doesn't exist (eager creation) + const existingFlow = await db.query.flows.findFirst({ + where: eq(flows.id, updates.flowId), + }); + + if (!existingFlow) { + await db.insert(flows).values({ + id: updates.flowId, + projectId: generation.projectId, + aliases: {}, + meta: {}, + }); + } + } + + // Update database fields + const updateData: Partial = {}; + if (updates.prompt !== undefined) { + updateData.originalPrompt = updates.prompt; + } + if (updates.aspectRatio !== undefined) { + updateData.aspectRatio = updates.aspectRatio; + } + if (updates.flowId !== undefined) { + updateData.flowId = updates.flowId; + } + if (updates.meta !== undefined) { + updateData.meta = updates.meta; + } + + if (Object.keys(updateData).length > 0) { + await db + .update(generations) + .set({ ...updateData, updatedAt: new Date() }) + .where(eq(generations.id, id)); + } + + // If generative parameters changed, trigger regeneration + if (shouldRegenerate && generation.outputImageId) { + // Update status to processing + await this.updateStatus(id, 'processing'); + + try { + // Use updated prompt/aspectRatio or fall back to existing + const promptToUse = updates.prompt || generation.originalPrompt; + const aspectRatioToUse = updates.aspectRatio || generation.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO; + + // Regenerate image + const genResult = await this.imageGenService.generateImage({ + prompt: promptToUse, + filename: `gen_${id}`, + referenceImages: [], + aspectRatio: aspectRatioToUse, + orgId: 'default', + projectId: generation.projectId, + meta: updates.meta || generation.meta || {}, + }); + + if (!genResult.success) { + await this.updateStatus(id, 'failed', { + errorMessage: genResult.error || 'Regeneration failed', + }); + throw new Error(genResult.error || 'Regeneration failed'); + } + + // Note: Physical file in MinIO is overwritten by ImageGenService + // TODO: Update fileSize and other metadata when ImageService.update() supports it + + await this.updateStatus(id, 'success'); + } catch (error) { + await this.updateStatus(id, 'failed', { + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; + } + } + + return await this.getByIdWithRelations(id); + } + async delete(id: string): Promise { const generation = await this.getById(id); if (!generation) { diff --git a/apps/api-service/src/types/requests.ts b/apps/api-service/src/types/requests.ts index f397874..06b94bc 100644 --- a/apps/api-service/src/types/requests.ts +++ b/apps/api-service/src/types/requests.ts @@ -9,8 +9,8 @@ export interface CreateGenerationRequest { referenceImages?: string[]; // Array of aliases to resolve aspectRatio?: string; // e.g., "1:1", "16:9", "3:2", "9:16" flowId?: string; - assignAlias?: string; // Alias to assign to generated image - assignFlowAlias?: Record; // Flow-scoped aliases to assign + alias?: string; // Alias to assign to generated image + flowAlias?: string; // Flow-scoped alias to assign autoEnhance?: boolean; enhancementOptions?: { template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general'; @@ -31,6 +31,13 @@ export interface RetryGenerationRequest { aspectRatio?: string; // Optional: override original aspect ratio } +export interface UpdateGenerationRequest { + prompt?: string; // Change prompt (triggers regeneration) + aspectRatio?: string; // Change aspect ratio (triggers regeneration) + flowId?: string | null; // Change/remove/add flow association (null to detach) + meta?: Record; // Update metadata +} + // ======================================== // IMAGE ENDPOINTS // ======================================== @@ -38,7 +45,7 @@ export interface RetryGenerationRequest { export interface UploadImageRequest { alias?: string; // Project-scoped alias flowId?: string; - flowAliases?: Record; // Flow-scoped aliases + flowAlias?: string; // Flow-scoped alias meta?: Record; } diff --git a/apps/api-service/src/utils/validators/aliasValidator.ts b/apps/api-service/src/utils/validators/aliasValidator.ts index e5793f1..abfbd00 100644 --- a/apps/api-service/src/utils/validators/aliasValidator.ts +++ b/apps/api-service/src/utils/validators/aliasValidator.ts @@ -2,7 +2,8 @@ import { ALIAS_PATTERN, ALIAS_MAX_LENGTH, isReservedAlias, - isTechnicalAlias + isTechnicalAlias, + isValidAliasFormat } from '../constants/aliases'; import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors'; @@ -97,3 +98,31 @@ export const validateTechnicalAliasWithFlow = ( return { valid: true }; }; + +/** + * Extract all aliases from a prompt text + * Pattern: space followed by @ followed by alphanumeric, dash, or underscore + * Example: "Create image based on @hero and @background" -> ["@hero", "@background"] + */ +export const extractAliasesFromPrompt = (prompt: string): string[] => { + if (!prompt || typeof prompt !== 'string') { + return []; + } + + // Pattern: space then @ then word characters (including dash and underscore) + // Also match @ at the beginning of the string + const aliasPattern = /(?:^|\s)(@[\w-]+)/g; + const matches: string[] = []; + let match; + + while ((match = aliasPattern.exec(prompt)) !== null) { + const alias = match[1]!; + // Validate format and max length + if (isValidAliasFormat(alias)) { + matches.push(alias); + } + } + + // Remove duplicates while preserving order + return Array.from(new Set(matches)); +}; diff --git a/docs/api/image-generation.rest b/docs/api/image-generation.rest index 4483cb6..d374389 100644 --- a/docs/api/image-generation.rest +++ b/docs/api/image-generation.rest @@ -14,8 +14,8 @@ X-API-Key: {{apiKey}} { "prompt": "A majestic eagle soaring over snow-capped mountains", "aspectRatio": "16:9", - "assignAlias": "@eagle-hero", - "assignFlowAlias": "@hero", + "alias": "@eagle-hero", + "flowAlias": "@hero", "autoEnhance": true, "meta": { "tags": ["demo", "nature"]