feature/api-development #1

Merged
usulpro merged 47 commits from feature/api-development into main 2025-11-29 23:03:01 +07:00
5 changed files with 251 additions and 28 deletions
Showing only changes of commit 647f66db7a - Show all commits

View File

@ -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<GetGenerationResponse>) => {
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

View File

@ -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<string, string> | undefined;
alias?: string | undefined;
flowAlias?: string | undefined;
autoEnhance?: boolean | undefined;
enhancedPrompt?: string | undefined;
meta?: Record<string, unknown> | undefined;
@ -49,9 +51,29 @@ export class GenerationService {
async create(params: CreateGenerationParams): Promise<GenerationWithRelations> {
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<string, string>,
flowAlias: string,
imageId: string
): Promise<void> {
const flow = await db.query.flows.findFirst({
@ -226,11 +263,8 @@ export class GenerationService {
const currentAliases = (flow.aliases as Record<string, string>) || {};
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<string, unknown>;
}
): Promise<GenerationWithRelations> {
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<NewGeneration> = {};
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<void> {
const generation = await this.getById(id);
if (!generation) {

View File

@ -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<string, string>; // 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<string, unknown>; // Update metadata
}
// ========================================
// IMAGE ENDPOINTS
// ========================================
@ -38,7 +45,7 @@ export interface RetryGenerationRequest {
export interface UploadImageRequest {
alias?: string; // Project-scoped alias
flowId?: string;
flowAliases?: Record<string, string>; // Flow-scoped aliases
flowAlias?: string; // Flow-scoped alias
meta?: Record<string, unknown>;
}

View File

@ -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));
};

View File

@ -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"]