mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(picture): migrate batch generation from Supabase to NestJS API
- Add BatchModule with controller, service, and DTOs - Create batch_generations database schema and migration - Add batch API client for mobile app - Replace Supabase Realtime with polling in batchStore - Implement full CRUD operations for batch generations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4b08c41547
commit
3a8d6bcf94
22 changed files with 3928 additions and 243 deletions
|
|
@ -10,6 +10,8 @@ import { BoardItemModule } from './board-item/board-item.module';
|
|||
import { UploadModule } from './upload/upload.module';
|
||||
import { GenerateModule } from './generate/generate.module';
|
||||
import { ExploreModule } from './explore/explore.module';
|
||||
import { ProfileModule } from './profile/profile.module';
|
||||
import { BatchModule } from './batch/batch.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -27,6 +29,8 @@ import { ExploreModule } from './explore/explore.module';
|
|||
UploadModule,
|
||||
GenerateModule,
|
||||
ExploreModule,
|
||||
ProfileModule,
|
||||
BatchModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
100
apps/picture/apps/backend/src/batch/batch.controller.ts
Normal file
100
apps/picture/apps/backend/src/batch/batch.controller.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BatchService } from './batch.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import {
|
||||
CurrentUser,
|
||||
CurrentUserData,
|
||||
} from '../common/decorators/current-user.decorator';
|
||||
import { CreateBatchDto, GetBatchQueryDto } from './dto/batch.dto';
|
||||
|
||||
@Controller('batch')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BatchController {
|
||||
constructor(private readonly batchService: BatchService) {}
|
||||
|
||||
/**
|
||||
* Create a new batch generation
|
||||
*/
|
||||
@Post()
|
||||
async createBatch(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: CreateBatchDto,
|
||||
) {
|
||||
return this.batchService.createBatch(user.userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all batches for the current user
|
||||
*/
|
||||
@Get()
|
||||
async getUserBatches(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query() query: GetBatchQueryDto,
|
||||
) {
|
||||
return this.batchService.getUserBatches(user.userId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific batch by ID with its items
|
||||
*/
|
||||
@Get(':id')
|
||||
async getBatch(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') batchId: string,
|
||||
) {
|
||||
return this.batchService.getBatch(batchId, user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch progress (for polling)
|
||||
*/
|
||||
@Get(':id/progress')
|
||||
async getBatchProgress(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') batchId: string,
|
||||
) {
|
||||
return this.batchService.getBatchProgress(batchId, user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed generations in a batch
|
||||
*/
|
||||
@Post(':id/retry')
|
||||
async retryFailed(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') batchId: string,
|
||||
) {
|
||||
return this.batchService.retryFailed(batchId, user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a batch
|
||||
*/
|
||||
@Post(':id/cancel')
|
||||
async cancelBatch(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') batchId: string,
|
||||
) {
|
||||
return this.batchService.cancelBatch(batchId, user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch and all its generations
|
||||
*/
|
||||
@Delete(':id')
|
||||
async deleteBatch(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') batchId: string,
|
||||
) {
|
||||
return this.batchService.deleteBatch(batchId, user.userId);
|
||||
}
|
||||
}
|
||||
12
apps/picture/apps/backend/src/batch/batch.module.ts
Normal file
12
apps/picture/apps/backend/src/batch/batch.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { BatchController } from './batch.controller';
|
||||
import { BatchService } from './batch.service';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [BatchController],
|
||||
providers: [BatchService],
|
||||
exports: [BatchService],
|
||||
})
|
||||
export class BatchModule {}
|
||||
445
apps/picture/apps/backend/src/batch/batch.service.ts
Normal file
445
apps/picture/apps/backend/src/batch/batch.service.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
batchGenerations,
|
||||
imageGenerations,
|
||||
type BatchGeneration,
|
||||
type NewBatchGeneration,
|
||||
} from '../db/schema';
|
||||
import { CreateBatchDto, GetBatchQueryDto } from './dto/batch.dto';
|
||||
|
||||
export interface BatchWithItems extends BatchGeneration {
|
||||
items?: {
|
||||
id: string;
|
||||
index: number;
|
||||
prompt: string;
|
||||
status: string;
|
||||
errorMessage?: string | null;
|
||||
retryCount?: number;
|
||||
imageUrl?: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BatchService {
|
||||
private readonly logger = new Logger(BatchService.name);
|
||||
|
||||
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
|
||||
|
||||
/**
|
||||
* Create a new batch generation
|
||||
*/
|
||||
async createBatch(
|
||||
userId: string,
|
||||
dto: CreateBatchDto,
|
||||
): Promise<BatchWithItems> {
|
||||
try {
|
||||
// Create the batch record
|
||||
const [batch] = await this.db
|
||||
.insert(batchGenerations)
|
||||
.values({
|
||||
userId,
|
||||
name: dto.batchName || `Batch ${new Date().toLocaleString()}`,
|
||||
totalCount: dto.prompts.length,
|
||||
completedCount: 0,
|
||||
failedCount: 0,
|
||||
processingCount: 0,
|
||||
pendingCount: dto.prompts.length,
|
||||
status: 'pending',
|
||||
modelId: dto.sharedSettings.modelId,
|
||||
modelVersion: dto.sharedSettings.modelVersion,
|
||||
width: dto.sharedSettings.width,
|
||||
height: dto.sharedSettings.height,
|
||||
steps: dto.sharedSettings.steps,
|
||||
guidanceScale: dto.sharedSettings.guidanceScale,
|
||||
} as NewBatchGeneration)
|
||||
.returning();
|
||||
|
||||
// Create individual generation records for each prompt
|
||||
const generationRecords = dto.prompts.map((prompt, index) => ({
|
||||
userId,
|
||||
batchId: batch.id,
|
||||
prompt: prompt.text,
|
||||
negativePrompt: prompt.negativePrompt,
|
||||
seed: prompt.seed,
|
||||
model: dto.sharedSettings.modelVersion,
|
||||
width: dto.sharedSettings.width,
|
||||
height: dto.sharedSettings.height,
|
||||
steps: dto.sharedSettings.steps,
|
||||
guidanceScale: dto.sharedSettings.guidanceScale,
|
||||
status: 'pending' as const,
|
||||
priority: index,
|
||||
}));
|
||||
|
||||
const generations = await this.db
|
||||
.insert(imageGenerations)
|
||||
.values(generationRecords)
|
||||
.returning();
|
||||
|
||||
// Return batch with items
|
||||
return {
|
||||
...batch,
|
||||
items: generations.map((gen, index) => ({
|
||||
id: gen.id,
|
||||
index,
|
||||
prompt: gen.prompt,
|
||||
status: gen.status,
|
||||
errorMessage: gen.errorMessage,
|
||||
retryCount: gen.retryCount,
|
||||
imageUrl: null,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Error creating batch', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a batch by ID with its items
|
||||
*/
|
||||
async getBatch(batchId: string, userId: string): Promise<BatchWithItems> {
|
||||
try {
|
||||
// Get batch
|
||||
const [batch] = await this.db
|
||||
.select()
|
||||
.from(batchGenerations)
|
||||
.where(eq(batchGenerations.id, batchId))
|
||||
.limit(1);
|
||||
|
||||
if (!batch) {
|
||||
throw new NotFoundException(`Batch with id ${batchId} not found`);
|
||||
}
|
||||
|
||||
if (batch.userId !== userId) {
|
||||
throw new ForbiddenException('Access denied');
|
||||
}
|
||||
|
||||
// Get items
|
||||
const items = await this.db
|
||||
.select({
|
||||
id: imageGenerations.id,
|
||||
prompt: imageGenerations.prompt,
|
||||
status: imageGenerations.status,
|
||||
errorMessage: imageGenerations.errorMessage,
|
||||
retryCount: imageGenerations.retryCount,
|
||||
priority: imageGenerations.priority,
|
||||
})
|
||||
.from(imageGenerations)
|
||||
.where(eq(imageGenerations.batchId, batchId))
|
||||
.orderBy(imageGenerations.priority);
|
||||
|
||||
return {
|
||||
...batch,
|
||||
items: items.map((item, index) => ({
|
||||
id: item.id,
|
||||
index,
|
||||
prompt: item.prompt,
|
||||
status: item.status,
|
||||
errorMessage: item.errorMessage,
|
||||
retryCount: item.retryCount ?? 0,
|
||||
imageUrl: null, // TODO: Join with images table to get URL
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof NotFoundException ||
|
||||
error instanceof ForbiddenException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Error getting batch ${batchId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all batches for a user
|
||||
*/
|
||||
async getUserBatches(
|
||||
userId: string,
|
||||
query: GetBatchQueryDto,
|
||||
): Promise<BatchGeneration[]> {
|
||||
try {
|
||||
const { page = 1, limit = 20 } = query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const batches = await this.db
|
||||
.select()
|
||||
.from(batchGenerations)
|
||||
.where(eq(batchGenerations.userId, userId))
|
||||
.orderBy(desc(batchGenerations.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return batches;
|
||||
} catch (error) {
|
||||
this.logger.error('Error getting user batches', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch progress (counts)
|
||||
*/
|
||||
async getBatchProgress(
|
||||
batchId: string,
|
||||
userId: string,
|
||||
): Promise<{
|
||||
totalCount: number;
|
||||
completedCount: number;
|
||||
failedCount: number;
|
||||
processingCount: number;
|
||||
pendingCount: number;
|
||||
status: string;
|
||||
}> {
|
||||
try {
|
||||
// Verify ownership
|
||||
const [batch] = await this.db
|
||||
.select()
|
||||
.from(batchGenerations)
|
||||
.where(eq(batchGenerations.id, batchId))
|
||||
.limit(1);
|
||||
|
||||
if (!batch) {
|
||||
throw new NotFoundException(`Batch with id ${batchId} not found`);
|
||||
}
|
||||
|
||||
if (batch.userId !== userId) {
|
||||
throw new ForbiddenException('Access denied');
|
||||
}
|
||||
|
||||
// Get actual counts from image_generations
|
||||
const counts = await this.db
|
||||
.select({
|
||||
status: imageGenerations.status,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(imageGenerations)
|
||||
.where(eq(imageGenerations.batchId, batchId))
|
||||
.groupBy(imageGenerations.status);
|
||||
|
||||
const statusCounts: Record<string, number> = {};
|
||||
counts.forEach((c) => {
|
||||
statusCounts[c.status] = Number(c.count);
|
||||
});
|
||||
|
||||
const completedCount = statusCounts['completed'] || 0;
|
||||
const failedCount = statusCounts['failed'] || 0;
|
||||
const processingCount = statusCounts['processing'] || 0;
|
||||
const pendingCount =
|
||||
(statusCounts['pending'] || 0) + (statusCounts['queued'] || 0);
|
||||
|
||||
// Determine overall status
|
||||
let status: string = batch.status;
|
||||
const totalCount = batch.totalCount;
|
||||
|
||||
if (completedCount === totalCount) {
|
||||
status = 'completed';
|
||||
} else if (failedCount === totalCount) {
|
||||
status = 'failed';
|
||||
} else if (completedCount > 0 && failedCount > 0) {
|
||||
status = 'partial';
|
||||
} else if (processingCount > 0 || pendingCount > 0) {
|
||||
status = 'processing';
|
||||
}
|
||||
|
||||
// Update batch if status changed
|
||||
if (status !== batch.status) {
|
||||
await this.db
|
||||
.update(batchGenerations)
|
||||
.set({
|
||||
status: status as any,
|
||||
completedCount,
|
||||
failedCount,
|
||||
processingCount,
|
||||
pendingCount,
|
||||
completedAt:
|
||||
status === 'completed' || status === 'failed'
|
||||
? new Date()
|
||||
: null,
|
||||
})
|
||||
.where(eq(batchGenerations.id, batchId));
|
||||
}
|
||||
|
||||
return {
|
||||
totalCount,
|
||||
completedCount,
|
||||
failedCount,
|
||||
processingCount,
|
||||
pendingCount,
|
||||
status,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof NotFoundException ||
|
||||
error instanceof ForbiddenException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Error getting batch progress ${batchId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed generations in a batch
|
||||
*/
|
||||
async retryFailed(batchId: string, userId: string): Promise<{ affected: number }> {
|
||||
try {
|
||||
// Verify ownership
|
||||
const [batch] = await this.db
|
||||
.select()
|
||||
.from(batchGenerations)
|
||||
.where(eq(batchGenerations.id, batchId))
|
||||
.limit(1);
|
||||
|
||||
if (!batch) {
|
||||
throw new NotFoundException(`Batch with id ${batchId} not found`);
|
||||
}
|
||||
|
||||
if (batch.userId !== userId) {
|
||||
throw new ForbiddenException('Access denied');
|
||||
}
|
||||
|
||||
// Reset failed generations to pending
|
||||
const result = await this.db
|
||||
.update(imageGenerations)
|
||||
.set({
|
||||
status: 'pending',
|
||||
errorMessage: null,
|
||||
retryCount: 0,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(imageGenerations.batchId, batchId),
|
||||
eq(imageGenerations.status, 'failed'),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
// Update batch status
|
||||
await this.db
|
||||
.update(batchGenerations)
|
||||
.set({
|
||||
status: 'processing',
|
||||
failedCount: 0,
|
||||
})
|
||||
.where(eq(batchGenerations.id, batchId));
|
||||
|
||||
return { affected: result.length };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof NotFoundException ||
|
||||
error instanceof ForbiddenException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Error retrying batch ${batchId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a batch
|
||||
*/
|
||||
async cancelBatch(batchId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
// Verify ownership
|
||||
const [batch] = await this.db
|
||||
.select()
|
||||
.from(batchGenerations)
|
||||
.where(eq(batchGenerations.id, batchId))
|
||||
.limit(1);
|
||||
|
||||
if (!batch) {
|
||||
throw new NotFoundException(`Batch with id ${batchId} not found`);
|
||||
}
|
||||
|
||||
if (batch.userId !== userId) {
|
||||
throw new ForbiddenException('Access denied');
|
||||
}
|
||||
|
||||
// Cancel pending generations
|
||||
await this.db
|
||||
.update(imageGenerations)
|
||||
.set({
|
||||
status: 'cancelled',
|
||||
errorMessage: 'Cancelled by user',
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(imageGenerations.batchId, batchId),
|
||||
eq(imageGenerations.status, 'pending'),
|
||||
),
|
||||
);
|
||||
|
||||
// Update batch status
|
||||
await this.db
|
||||
.update(batchGenerations)
|
||||
.set({
|
||||
status: 'failed',
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(batchGenerations.id, batchId));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof NotFoundException ||
|
||||
error instanceof ForbiddenException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Error cancelling batch ${batchId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch and all its generations
|
||||
*/
|
||||
async deleteBatch(batchId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
// Verify ownership
|
||||
const [batch] = await this.db
|
||||
.select()
|
||||
.from(batchGenerations)
|
||||
.where(eq(batchGenerations.id, batchId))
|
||||
.limit(1);
|
||||
|
||||
if (!batch) {
|
||||
throw new NotFoundException(`Batch with id ${batchId} not found`);
|
||||
}
|
||||
|
||||
if (batch.userId !== userId) {
|
||||
throw new ForbiddenException('Access denied');
|
||||
}
|
||||
|
||||
// Delete generations first
|
||||
await this.db
|
||||
.delete(imageGenerations)
|
||||
.where(eq(imageGenerations.batchId, batchId));
|
||||
|
||||
// Delete batch
|
||||
await this.db
|
||||
.delete(batchGenerations)
|
||||
.where(eq(batchGenerations.id, batchId));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof NotFoundException ||
|
||||
error instanceof ForbiddenException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Error deleting batch ${batchId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
74
apps/picture/apps/backend/src/batch/dto/batch.dto.ts
Normal file
74
apps/picture/apps/backend/src/batch/dto/batch.dto.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class BatchPromptDto {
|
||||
@IsString()
|
||||
text: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
negativePrompt?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
seed?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class SharedSettingsDto {
|
||||
@IsUUID()
|
||||
modelId: string;
|
||||
|
||||
@IsString()
|
||||
modelVersion: string;
|
||||
|
||||
@IsNumber()
|
||||
width: number;
|
||||
|
||||
@IsNumber()
|
||||
height: number;
|
||||
|
||||
@IsNumber()
|
||||
steps: number;
|
||||
|
||||
@IsNumber()
|
||||
guidanceScale: number;
|
||||
}
|
||||
|
||||
export class CreateBatchDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchPromptDto)
|
||||
prompts: BatchPromptDto[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => SharedSettingsDto)
|
||||
sharedSettings: SharedSettingsDto;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
batchName?: string;
|
||||
}
|
||||
|
||||
export class GetBatchQueryDto {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number = 1;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
limit?: number = 20;
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
CREATE TYPE "public"."generation_status" AS ENUM('pending', 'queued', 'processing', 'completed', 'failed', 'cancelled');--> statement-breakpoint
|
||||
CREATE TYPE "public"."item_type" AS ENUM('image', 'text');--> statement-breakpoint
|
||||
CREATE TABLE "images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"generation_id" uuid,
|
||||
"source_image_id" uuid,
|
||||
"prompt" text NOT NULL,
|
||||
"negative_prompt" text,
|
||||
"model" text,
|
||||
"style" text,
|
||||
"public_url" text,
|
||||
"storage_path" text NOT NULL,
|
||||
"filename" text NOT NULL,
|
||||
"format" text,
|
||||
"width" integer,
|
||||
"height" integer,
|
||||
"file_size" integer,
|
||||
"blurhash" text,
|
||||
"is_public" boolean DEFAULT false NOT NULL,
|
||||
"is_favorite" boolean DEFAULT false NOT NULL,
|
||||
"download_count" integer DEFAULT 0 NOT NULL,
|
||||
"rating" integer,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "image_generations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"model_id" uuid,
|
||||
"batch_id" uuid,
|
||||
"prompt" text NOT NULL,
|
||||
"negative_prompt" text,
|
||||
"model" text,
|
||||
"style" text,
|
||||
"source_image_url" text,
|
||||
"width" integer,
|
||||
"height" integer,
|
||||
"steps" integer,
|
||||
"guidance_scale" real,
|
||||
"seed" integer,
|
||||
"generation_strength" real,
|
||||
"status" "generation_status" DEFAULT 'pending' NOT NULL,
|
||||
"replicate_prediction_id" text,
|
||||
"error_message" text,
|
||||
"generation_time_seconds" integer,
|
||||
"retry_count" integer DEFAULT 0 NOT NULL,
|
||||
"priority" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "boards" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"thumbnail_url" text,
|
||||
"canvas_width" integer DEFAULT 2000 NOT NULL,
|
||||
"canvas_height" integer DEFAULT 1500 NOT NULL,
|
||||
"background_color" text DEFAULT '#ffffff' NOT NULL,
|
||||
"is_public" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "board_items" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"board_id" uuid NOT NULL,
|
||||
"image_id" uuid,
|
||||
"item_type" "item_type" DEFAULT 'image' NOT NULL,
|
||||
"position_x" real DEFAULT 0 NOT NULL,
|
||||
"position_y" real DEFAULT 0 NOT NULL,
|
||||
"scale_x" real DEFAULT 1 NOT NULL,
|
||||
"scale_y" real DEFAULT 1 NOT NULL,
|
||||
"rotation" real DEFAULT 0 NOT NULL,
|
||||
"z_index" integer DEFAULT 0 NOT NULL,
|
||||
"opacity" real DEFAULT 1 NOT NULL,
|
||||
"width" integer,
|
||||
"height" integer,
|
||||
"text_content" text,
|
||||
"font_size" integer,
|
||||
"color" text,
|
||||
"properties" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "image_tags" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"image_id" uuid NOT NULL,
|
||||
"tag_id" uuid NOT NULL,
|
||||
"added_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tags" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "tags_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "models" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"description" text,
|
||||
"replicate_id" text NOT NULL,
|
||||
"version" text,
|
||||
"default_width" integer DEFAULT 1024,
|
||||
"default_height" integer DEFAULT 1024,
|
||||
"default_steps" integer DEFAULT 25,
|
||||
"default_guidance_scale" real DEFAULT 7.5,
|
||||
"min_width" integer DEFAULT 512,
|
||||
"min_height" integer DEFAULT 512,
|
||||
"max_width" integer DEFAULT 2048,
|
||||
"max_height" integer DEFAULT 2048,
|
||||
"max_steps" integer DEFAULT 50,
|
||||
"supports_negative_prompt" boolean DEFAULT true NOT NULL,
|
||||
"supports_img2img" boolean DEFAULT false NOT NULL,
|
||||
"supports_seed" boolean DEFAULT true NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"is_default" boolean DEFAULT false NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"cost_per_generation" real,
|
||||
"estimated_time_seconds" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
CREATE TYPE "public"."batch_status" AS ENUM('pending', 'processing', 'completed', 'partial', 'failed');--> statement-breakpoint
|
||||
CREATE TABLE "batch_generations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"name" text,
|
||||
"total_count" integer NOT NULL,
|
||||
"completed_count" integer DEFAULT 0 NOT NULL,
|
||||
"failed_count" integer DEFAULT 0 NOT NULL,
|
||||
"processing_count" integer DEFAULT 0 NOT NULL,
|
||||
"pending_count" integer DEFAULT 0 NOT NULL,
|
||||
"status" "batch_status" DEFAULT 'pending' NOT NULL,
|
||||
"model_id" uuid,
|
||||
"model_version" text,
|
||||
"width" integer,
|
||||
"height" integer,
|
||||
"steps" integer,
|
||||
"guidance_scale" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone
|
||||
);
|
||||
|
|
@ -0,0 +1,839 @@
|
|||
{
|
||||
"id": "8be49bb1-e2e5-474b-816d-8b27c6bb7c13",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.images": {
|
||||
"name": "images",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"generation_id": {
|
||||
"name": "generation_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_image_id": {
|
||||
"name": "source_image_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"style": {
|
||||
"name": "style",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"public_url": {
|
||||
"name": "public_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"storage_path": {
|
||||
"name": "storage_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"blurhash": {
|
||||
"name": "blurhash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"is_favorite": {
|
||||
"name": "is_favorite",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"download_count": {
|
||||
"name": "download_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"archived_at": {
|
||||
"name": "archived_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.image_generations": {
|
||||
"name": "image_generations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"model_id": {
|
||||
"name": "model_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"batch_id": {
|
||||
"name": "batch_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"style": {
|
||||
"name": "style",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_image_url": {
|
||||
"name": "source_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"guidance_scale": {
|
||||
"name": "guidance_scale",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"generation_strength": {
|
||||
"name": "generation_strength",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "generation_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"replicate_prediction_id": {
|
||||
"name": "replicate_prediction_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"error_message": {
|
||||
"name": "error_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"generation_time_seconds": {
|
||||
"name": "generation_time_seconds",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"retry_count": {
|
||||
"name": "retry_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.boards": {
|
||||
"name": "boards",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"canvas_width": {
|
||||
"name": "canvas_width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 2000
|
||||
},
|
||||
"canvas_height": {
|
||||
"name": "canvas_height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1500
|
||||
},
|
||||
"background_color": {
|
||||
"name": "background_color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'#ffffff'"
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.board_items": {
|
||||
"name": "board_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"board_id": {
|
||||
"name": "board_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_id": {
|
||||
"name": "image_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"item_type": {
|
||||
"name": "item_type",
|
||||
"type": "item_type",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'image'"
|
||||
},
|
||||
"position_x": {
|
||||
"name": "position_x",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"position_y": {
|
||||
"name": "position_y",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"scale_x": {
|
||||
"name": "scale_x",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"scale_y": {
|
||||
"name": "scale_y",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"rotation": {
|
||||
"name": "rotation",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"z_index": {
|
||||
"name": "z_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"opacity": {
|
||||
"name": "opacity",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"text_content": {
|
||||
"name": "text_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"font_size": {
|
||||
"name": "font_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"properties": {
|
||||
"name": "properties",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.image_tags": {
|
||||
"name": "image_tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"image_id": {
|
||||
"name": "image_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"added_at": {
|
||||
"name": "added_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tags": {
|
||||
"name": "tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.models": {
|
||||
"name": "models",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"replicate_id": {
|
||||
"name": "replicate_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"default_width": {
|
||||
"name": "default_width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1024
|
||||
},
|
||||
"default_height": {
|
||||
"name": "default_height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 1024
|
||||
},
|
||||
"default_steps": {
|
||||
"name": "default_steps",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 25
|
||||
},
|
||||
"default_guidance_scale": {
|
||||
"name": "default_guidance_scale",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 7.5
|
||||
},
|
||||
"min_width": {
|
||||
"name": "min_width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 512
|
||||
},
|
||||
"min_height": {
|
||||
"name": "min_height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 512
|
||||
},
|
||||
"max_width": {
|
||||
"name": "max_width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 2048
|
||||
},
|
||||
"max_height": {
|
||||
"name": "max_height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 2048
|
||||
},
|
||||
"max_steps": {
|
||||
"name": "max_steps",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 50
|
||||
},
|
||||
"supports_negative_prompt": {
|
||||
"name": "supports_negative_prompt",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"supports_img2img": {
|
||||
"name": "supports_img2img",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"supports_seed": {
|
||||
"name": "supports_seed",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"is_active": {
|
||||
"name": "is_active",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"is_default": {
|
||||
"name": "is_default",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"cost_per_generation": {
|
||||
"name": "cost_per_generation",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"estimated_time_seconds": {
|
||||
"name": "estimated_time_seconds",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.generation_status": {
|
||||
"name": "generation_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"pending",
|
||||
"queued",
|
||||
"processing",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"public.item_type": {
|
||||
"name": "item_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"image",
|
||||
"text"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
1086
apps/picture/apps/backend/src/db/migrations/meta/0001_snapshot.json
Normal file
1086
apps/picture/apps/backend/src/db/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1764242583001,
|
||||
"tag": "0000_clever_clint_barton",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1764250151767,
|
||||
"tag": "0001_woozy_human_torch",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
pgEnum,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export const batchStatusEnum = pgEnum('batch_status', [
|
||||
'pending',
|
||||
'processing',
|
||||
'completed',
|
||||
'partial',
|
||||
'failed',
|
||||
]);
|
||||
|
||||
export const batchGenerations = pgTable('batch_generations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
name: text('name'),
|
||||
|
||||
totalCount: integer('total_count').notNull(),
|
||||
completedCount: integer('completed_count').default(0).notNull(),
|
||||
failedCount: integer('failed_count').default(0).notNull(),
|
||||
processingCount: integer('processing_count').default(0).notNull(),
|
||||
pendingCount: integer('pending_count').default(0).notNull(),
|
||||
|
||||
status: batchStatusEnum('status').default('pending').notNull(),
|
||||
|
||||
// Shared settings for all generations in the batch
|
||||
modelId: uuid('model_id'),
|
||||
modelVersion: text('model_version'),
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
steps: integer('steps'),
|
||||
guidanceScale: integer('guidance_scale'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
export type BatchGeneration = typeof batchGenerations.$inferSelect;
|
||||
export type NewBatchGeneration = typeof batchGenerations.$inferInsert;
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
export * from './images.schema';
|
||||
export * from './image-generations.schema';
|
||||
export * from './image-likes.schema';
|
||||
export * from './batch-generations.schema';
|
||||
export * from './boards.schema';
|
||||
export * from './board-items.schema';
|
||||
export * from './tags.schema';
|
||||
export * from './models.schema';
|
||||
export * from './profiles.schema';
|
||||
|
|
|
|||
151
apps/picture/apps/mobile/services/api/batch.ts
Normal file
151
apps/picture/apps/mobile/services/api/batch.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Batch API - Using NestJS Backend
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
|
||||
// Request DTOs (camelCase for API)
|
||||
export interface BatchPromptDto {
|
||||
text: string;
|
||||
negativePrompt?: string;
|
||||
seed?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface SharedSettingsDto {
|
||||
modelId: string;
|
||||
modelVersion: string;
|
||||
width: number;
|
||||
height: number;
|
||||
steps: number;
|
||||
guidanceScale: number;
|
||||
}
|
||||
|
||||
export interface CreateBatchDto {
|
||||
prompts: BatchPromptDto[];
|
||||
sharedSettings: SharedSettingsDto;
|
||||
batchName?: string;
|
||||
}
|
||||
|
||||
// Response types (camelCase from API)
|
||||
export interface BatchItem {
|
||||
id: string;
|
||||
index: number;
|
||||
prompt: string;
|
||||
status: string;
|
||||
errorMessage?: string | null;
|
||||
retryCount?: number;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface BatchGeneration {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string | null;
|
||||
totalCount: number;
|
||||
completedCount: number;
|
||||
failedCount: number;
|
||||
processingCount: number;
|
||||
pendingCount: number;
|
||||
status: 'pending' | 'processing' | 'completed' | 'partial' | 'failed';
|
||||
modelId?: string | null;
|
||||
modelVersion?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
steps?: number | null;
|
||||
guidanceScale?: number | null;
|
||||
createdAt: string;
|
||||
completedAt?: string | null;
|
||||
items?: BatchItem[];
|
||||
}
|
||||
|
||||
export interface BatchProgress {
|
||||
totalCount: number;
|
||||
completedCount: number;
|
||||
failedCount: number;
|
||||
processingCount: number;
|
||||
pendingCount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new batch generation
|
||||
*/
|
||||
export async function createBatch(dto: CreateBatchDto): Promise<BatchGeneration> {
|
||||
const { data, error } = await fetchApi<BatchGeneration>('/batch', {
|
||||
method: 'POST',
|
||||
body: dto,
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to create batch');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all batches for the current user
|
||||
*/
|
||||
export async function getUserBatches(
|
||||
page = 1,
|
||||
limit = 20
|
||||
): Promise<BatchGeneration[]> {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: String(limit),
|
||||
});
|
||||
|
||||
const { data, error } = await fetchApi<BatchGeneration[]>(`/batch?${searchParams}`);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific batch by ID with its items
|
||||
*/
|
||||
export async function getBatch(batchId: string): Promise<BatchGeneration> {
|
||||
const { data, error } = await fetchApi<BatchGeneration>(`/batch/${batchId}`);
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Batch not found');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch progress (for polling)
|
||||
*/
|
||||
export async function getBatchProgress(batchId: string): Promise<BatchProgress> {
|
||||
const { data, error } = await fetchApi<BatchProgress>(`/batch/${batchId}/progress`);
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to get batch progress');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed generations in a batch
|
||||
*/
|
||||
export async function retryFailed(batchId: string): Promise<{ affected: number }> {
|
||||
const { data, error } = await fetchApi<{ affected: number }>(
|
||||
`/batch/${batchId}/retry`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (error) throw error;
|
||||
return data || { affected: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a batch
|
||||
*/
|
||||
export async function cancelBatch(batchId: string): Promise<void> {
|
||||
const { error } = await fetchApi(`/batch/${batchId}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a batch and all its generations
|
||||
*/
|
||||
export async function deleteBatch(batchId: string): Promise<void> {
|
||||
const { error } = await fetchApi(`/batch/${batchId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
80
apps/picture/apps/mobile/services/api/client.ts
Normal file
80
apps/picture/apps/mobile/services/api/client.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* API Client for Picture Backend
|
||||
* Used by Mobile App to communicate with NestJS backend
|
||||
*/
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
const API_BASE = Constants.expoConfig?.extra?.backendUrl || process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3003';
|
||||
|
||||
type FetchOptions = {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get auth token from secure storage
|
||||
*/
|
||||
async function getAuthToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync('appToken');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API fetch function
|
||||
*/
|
||||
export async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: FetchOptions = {},
|
||||
): Promise<{ data: T | null; error: Error | null }> {
|
||||
const { method = 'GET', body, token } = options;
|
||||
|
||||
let authToken = token;
|
||||
if (!authToken) {
|
||||
authToken = await getAuthToken() || undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
data: null,
|
||||
error: new Error(errorData.message || `API error: ${response.status}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle empty responses (204 No Content)
|
||||
if (response.status === 204) {
|
||||
return { data: null, error: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error : new Error('Unknown error'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { API_BASE };
|
||||
62
apps/picture/apps/mobile/services/api/explore.ts
Normal file
62
apps/picture/apps/mobile/services/api/explore.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Explore API - Public Gallery
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import type { Image } from './images';
|
||||
|
||||
export type SortBy = 'recent' | 'popular' | 'trending';
|
||||
|
||||
export interface ExploreParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: SortBy;
|
||||
}
|
||||
|
||||
export interface SearchParams extends ExploreParams {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public images for explore/discover feed
|
||||
*/
|
||||
export async function getExploreImages(params: ExploreParams = {}): Promise<Image[]> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
sortBy = 'recent',
|
||||
} = params;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: String(limit),
|
||||
sortBy,
|
||||
});
|
||||
|
||||
const { data, error } = await fetchApi<Image[]>(`/explore?${searchParams}`);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search public images
|
||||
*/
|
||||
export async function searchExploreImages(params: SearchParams): Promise<Image[]> {
|
||||
const {
|
||||
searchTerm,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
sortBy = 'recent',
|
||||
} = params;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
searchTerm,
|
||||
page: String(page),
|
||||
limit: String(limit),
|
||||
sortBy,
|
||||
});
|
||||
|
||||
const { data, error } = await fetchApi<Image[]>(`/explore/search?${searchParams}`);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
179
apps/picture/apps/mobile/services/api/generate.ts
Normal file
179
apps/picture/apps/mobile/services/api/generate.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* Generate API - Using NestJS Backend
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
|
||||
export interface GenerateImageParams {
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelVersion?: string;
|
||||
negativePrompt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
steps?: number;
|
||||
guidanceScale?: number;
|
||||
seed?: number;
|
||||
sourceImageUrl?: string;
|
||||
generationStrength?: number;
|
||||
style?: string;
|
||||
waitForResult?: boolean;
|
||||
}
|
||||
|
||||
export interface GeneratedImage {
|
||||
id: string;
|
||||
userId: string;
|
||||
generationId?: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
model?: string;
|
||||
style?: string;
|
||||
publicUrl?: string;
|
||||
storagePath: string;
|
||||
filename: string;
|
||||
format?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fileSize?: number;
|
||||
blurhash?: string;
|
||||
isPublic: boolean;
|
||||
isFavorite: boolean;
|
||||
downloadCount: number;
|
||||
rating?: number;
|
||||
archivedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GenerateImageResponse {
|
||||
generationId: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
image?: GeneratedImage;
|
||||
}
|
||||
|
||||
export interface GenerationStatus {
|
||||
id: string;
|
||||
userId: string;
|
||||
modelId?: string;
|
||||
batchId?: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
model?: string;
|
||||
style?: string;
|
||||
sourceImageUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
steps?: number;
|
||||
guidanceScale?: number;
|
||||
seed?: number;
|
||||
generationStrength?: number;
|
||||
status: 'pending' | 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
replicatePredictionId?: string;
|
||||
errorMessage?: string;
|
||||
generationTimeSeconds?: number;
|
||||
retryCount: number;
|
||||
priority: number;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
image?: GeneratedImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start image generation
|
||||
* Set waitForResult=true for synchronous generation (waits until complete)
|
||||
*/
|
||||
export async function generateImage(params: GenerateImageParams): Promise<GenerateImageResponse> {
|
||||
const { data, error } = await fetchApi<GenerateImageResponse>('/generate', {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Generate Image Error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('Failed to start image generation');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check generation status
|
||||
*/
|
||||
export async function checkGenerationStatus(generationId: string): Promise<GenerationStatus> {
|
||||
const { data, error } = await fetchApi<GenerationStatus>(`/generate/${generationId}/status`);
|
||||
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Generation not found');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-progress generation
|
||||
*/
|
||||
export async function cancelGeneration(generationId: string): Promise<void> {
|
||||
const { error } = await fetchApi(`/generate/${generationId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for generation completion
|
||||
*/
|
||||
export async function waitForGeneration(
|
||||
generationId: string,
|
||||
onProgress?: (status: GenerationStatus) => void,
|
||||
pollInterval = 2000,
|
||||
maxAttempts = 120,
|
||||
): Promise<GenerationStatus> {
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const status = await checkGenerationStatus(generationId);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(status);
|
||||
}
|
||||
|
||||
if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') {
|
||||
return status;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
throw new Error('Generation timed out');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image and wait for completion (convenience function)
|
||||
*/
|
||||
export async function generateAndWait(
|
||||
params: Omit<GenerateImageParams, 'waitForResult'>,
|
||||
onProgress?: (status: GenerationStatus) => void,
|
||||
): Promise<GenerationStatus> {
|
||||
// Use synchronous mode for faster response
|
||||
const response = await generateImage({ ...params, waitForResult: true });
|
||||
|
||||
if (response.status === 'completed' && response.image) {
|
||||
return {
|
||||
id: response.generationId,
|
||||
userId: response.image.userId,
|
||||
prompt: params.prompt,
|
||||
status: 'completed',
|
||||
image: response.image,
|
||||
retryCount: 0,
|
||||
priority: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
} as GenerationStatus;
|
||||
}
|
||||
|
||||
// If not completed, poll for status
|
||||
return waitForGeneration(response.generationId, onProgress);
|
||||
}
|
||||
270
apps/picture/apps/mobile/services/api/images.ts
Normal file
270
apps/picture/apps/mobile/services/api/images.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* Images API - Using NestJS Backend
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
|
||||
export interface Image {
|
||||
id: string;
|
||||
userId: string;
|
||||
generationId?: string;
|
||||
sourceImageId?: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
model?: string;
|
||||
style?: string;
|
||||
publicUrl?: string;
|
||||
storagePath: string;
|
||||
filename: string;
|
||||
format?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fileSize?: number;
|
||||
blurhash?: string;
|
||||
isPublic: boolean;
|
||||
isFavorite: boolean;
|
||||
downloadCount: number;
|
||||
rating?: number;
|
||||
archivedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GetImagesParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
archived?: boolean;
|
||||
tagIds?: string[];
|
||||
favoritesOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get images for the current user
|
||||
*/
|
||||
export async function getImages(params: GetImagesParams = {}): Promise<Image[]> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
archived = false,
|
||||
tagIds,
|
||||
favoritesOnly = false,
|
||||
} = params;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: String(limit),
|
||||
archived: String(archived),
|
||||
favoritesOnly: String(favoritesOnly),
|
||||
});
|
||||
|
||||
if (tagIds && tagIds.length > 0) {
|
||||
searchParams.append('tagIds', tagIds.join(','));
|
||||
}
|
||||
|
||||
const { data, error } = await fetchApi<Image[]>(`/images?${searchParams}`);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image by ID
|
||||
*/
|
||||
export async function getImageById(id: string): Promise<Image> {
|
||||
const { data, error } = await fetchApi<Image>(`/images/${id}`);
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Image not found');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive an image
|
||||
*/
|
||||
export async function archiveImage(id: string): Promise<Image> {
|
||||
const { data, error } = await fetchApi<Image>(`/images/${id}/archive`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to archive image');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore (unarchive) an image
|
||||
*/
|
||||
export async function restoreImage(id: string): Promise<Image> {
|
||||
const { data, error } = await fetchApi<Image>(`/images/${id}/unarchive`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to restore image');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image permanently
|
||||
*/
|
||||
export async function deleteImage(id: string): Promise<void> {
|
||||
const { error } = await fetchApi(`/images/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an image (make public)
|
||||
*/
|
||||
export async function publishImage(id: string): Promise<Image> {
|
||||
const { data, error } = await fetchApi<Image>(`/images/${id}/publish`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to publish image');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish an image (make private)
|
||||
*/
|
||||
export async function unpublishImage(id: string): Promise<Image> {
|
||||
const { data, error } = await fetchApi<Image>(`/images/${id}/unpublish`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to unpublish image');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
export async function toggleFavorite(id: string, isFavorite: boolean): Promise<Image> {
|
||||
const { data, error } = await fetchApi<Image>(`/images/${id}/favorite`, {
|
||||
method: 'PATCH',
|
||||
body: { isFavorite },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to toggle favorite');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get archived images count
|
||||
*/
|
||||
export async function getArchivedCount(): Promise<number> {
|
||||
const { data, error } = await fetchApi<{ count: number }>('/images/archived/count');
|
||||
if (error) throw error;
|
||||
return data?.count || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get archived images
|
||||
*/
|
||||
export async function getArchivedImages(page = 1, limit = 20): Promise<{
|
||||
items: Image[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
const images = await getImages({ page, limit, archived: true });
|
||||
// Note: Backend doesn't return total count in list, so we estimate hasMore
|
||||
return {
|
||||
items: images,
|
||||
total: images.length,
|
||||
hasMore: images.length === limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch archive multiple images
|
||||
*/
|
||||
export async function batchArchiveImages(imageIds: string[]): Promise<{ affected: number }> {
|
||||
const { data, error } = await fetchApi<{ affected: number }>('/images/batch/archive', {
|
||||
method: 'POST',
|
||||
body: { imageIds },
|
||||
});
|
||||
if (error) throw error;
|
||||
return data || { affected: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch restore multiple images
|
||||
*/
|
||||
export async function batchRestoreImages(imageIds: string[]): Promise<{ affected: number }> {
|
||||
const { data, error } = await fetchApi<{ affected: number }>('/images/batch/restore', {
|
||||
method: 'POST',
|
||||
body: { imageIds },
|
||||
});
|
||||
if (error) throw error;
|
||||
return data || { affected: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete multiple images
|
||||
*/
|
||||
export async function batchDeleteImages(imageIds: string[]): Promise<{ affected: number }> {
|
||||
const { data, error } = await fetchApi<{ affected: number }>('/images/batch/delete', {
|
||||
method: 'POST',
|
||||
body: { imageIds },
|
||||
});
|
||||
if (error) throw error;
|
||||
return data || { affected: 0 };
|
||||
}
|
||||
|
||||
// ==================== LIKES ====================
|
||||
|
||||
export interface LikeStatus {
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like an image
|
||||
*/
|
||||
export async function likeImage(imageId: string): Promise<LikeStatus> {
|
||||
const { data, error } = await fetchApi<LikeStatus>(`/images/${imageId}/like`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (error) throw error;
|
||||
return data || { liked: false, likeCount: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike an image
|
||||
*/
|
||||
export async function unlikeImage(imageId: string): Promise<LikeStatus> {
|
||||
const { data, error } = await fetchApi<LikeStatus>(`/images/${imageId}/like`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (error) throw error;
|
||||
return data || { liked: false, likeCount: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get like status for an image
|
||||
*/
|
||||
export async function getLikeStatus(imageId: string): Promise<LikeStatus> {
|
||||
const { data, error } = await fetchApi<LikeStatus>(`/images/${imageId}/likes`);
|
||||
if (error) throw error;
|
||||
return data || { liked: false, likeCount: 0 };
|
||||
}
|
||||
|
||||
// ==================== GENERATION DETAILS ====================
|
||||
|
||||
export interface GenerationDetails {
|
||||
steps: number | null;
|
||||
guidanceScale: number | null;
|
||||
generationTimeSeconds: number | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get generation details for an image
|
||||
*/
|
||||
export async function getGenerationDetails(
|
||||
generationId: string
|
||||
): Promise<GenerationDetails | null> {
|
||||
const { data, error } = await fetchApi<GenerationDetails>(
|
||||
`/images/generation/${generationId}`
|
||||
);
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
}
|
||||
13
apps/picture/apps/mobile/services/api/index.ts
Normal file
13
apps/picture/apps/mobile/services/api/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Backend API Client
|
||||
*
|
||||
* All API calls go through the NestJS backend instead of Supabase.
|
||||
*/
|
||||
|
||||
export * from './client';
|
||||
export * from './generate';
|
||||
export * from './models';
|
||||
export * from './images';
|
||||
export * from './profiles';
|
||||
export * from './explore';
|
||||
export * from './tags';
|
||||
77
apps/picture/apps/mobile/services/api/models.ts
Normal file
77
apps/picture/apps/mobile/services/api/models.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Models API - Using NestJS Backend
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
|
||||
export interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
replicateId: string;
|
||||
version?: string;
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
defaultSteps?: number;
|
||||
defaultGuidanceScale?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
maxSteps?: number;
|
||||
supportsNegativePrompt: boolean;
|
||||
supportsImg2Img: boolean;
|
||||
supportsSeed: boolean;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
sortOrder: number;
|
||||
costPerGeneration?: number;
|
||||
estimatedTimeSeconds?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active models
|
||||
*/
|
||||
export async function getActiveModels(): Promise<Model[]> {
|
||||
console.log('Fetching models from backend...');
|
||||
|
||||
const { data, error } = await fetchApi<Model[]>('/models');
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching models:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Fetched ${data?.length || 0} models from backend`);
|
||||
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model by ID
|
||||
*/
|
||||
export async function getModelById(id: string): Promise<Model> {
|
||||
const { data, error } = await fetchApi<Model>(`/models/${id}`);
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching model:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error(`Model with id ${id} not found`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model
|
||||
*/
|
||||
export async function getDefaultModel(): Promise<Model | null> {
|
||||
const models = await getActiveModels();
|
||||
return models.find(m => m.isDefault) || models[0] || null;
|
||||
}
|
||||
63
apps/picture/apps/mobile/services/api/profiles.ts
Normal file
63
apps/picture/apps/mobile/services/api/profiles.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Profiles API - Using NestJS Backend
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
username: string | null;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
totalImages: number;
|
||||
favoriteImages: number;
|
||||
archivedImages: number;
|
||||
publicImages: number;
|
||||
}
|
||||
|
||||
export interface UpdateProfileData {
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's profile
|
||||
*/
|
||||
export async function getMyProfile(): Promise<Profile> {
|
||||
const { data, error } = await fetchApi<Profile>('/profiles/me');
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Profile not found');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user's profile
|
||||
*/
|
||||
export async function updateProfile(updateData: UpdateProfileData): Promise<Profile> {
|
||||
const { data, error } = await fetchApi<Profile>('/profiles/me', {
|
||||
method: 'PATCH',
|
||||
body: updateData,
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to update profile');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user statistics (images count, favorites, etc.)
|
||||
*/
|
||||
export async function getUserStats(): Promise<UserStats> {
|
||||
const { data, error } = await fetchApi<UserStats>('/profiles/stats');
|
||||
if (error) throw error;
|
||||
return data || {
|
||||
totalImages: 0,
|
||||
favoriteImages: 0,
|
||||
archivedImages: 0,
|
||||
publicImages: 0,
|
||||
};
|
||||
}
|
||||
96
apps/picture/apps/mobile/services/api/tags.ts
Normal file
96
apps/picture/apps/mobile/services/api/tags.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Tags API - Using NestJS Backend
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateTagData {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTagData {
|
||||
name?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags for current user
|
||||
*/
|
||||
export async function getTags(): Promise<Tag[]> {
|
||||
const { data, error } = await fetchApi<Tag[]>('/tags');
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tag
|
||||
*/
|
||||
export async function createTag(tagData: CreateTagData): Promise<Tag> {
|
||||
const { data, error } = await fetchApi<Tag>('/tags', {
|
||||
method: 'POST',
|
||||
body: tagData,
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to create tag');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing tag
|
||||
*/
|
||||
export async function updateTag(id: string, tagData: UpdateTagData): Promise<Tag> {
|
||||
const { data, error } = await fetchApi<Tag>(`/tags/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: tagData,
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error('Failed to update tag');
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag
|
||||
*/
|
||||
export async function deleteTag(id: string): Promise<void> {
|
||||
const { error } = await fetchApi(`/tags/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags for a specific image
|
||||
*/
|
||||
export async function getImageTags(imageId: string): Promise<Tag[]> {
|
||||
const { data, error } = await fetchApi<Tag[]>(`/tags/image/${imageId}`);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a tag to an image
|
||||
*/
|
||||
export async function addTagToImage(imageId: string, tagId: string): Promise<void> {
|
||||
const { error } = await fetchApi(`/tags/image/${imageId}/${tagId}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tag from an image
|
||||
*/
|
||||
export async function removeTagFromImage(imageId: string, tagId: string): Promise<void> {
|
||||
const { error } = await fetchApi(`/tags/image/${imageId}/${tagId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
|
@ -1,7 +1,22 @@
|
|||
import { create } from 'zustand';
|
||||
import { supabase } from '~/utils/supabase';
|
||||
import { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import {
|
||||
createBatch as apiCreateBatch,
|
||||
getBatch as apiGetBatch,
|
||||
getUserBatches as apiGetUserBatches,
|
||||
getBatchProgress as apiGetBatchProgress,
|
||||
retryFailed as apiRetryFailed,
|
||||
cancelBatch as apiCancelBatch,
|
||||
deleteBatch as apiDeleteBatch,
|
||||
type BatchGeneration,
|
||||
type BatchItem,
|
||||
type BatchPromptDto,
|
||||
type SharedSettingsDto,
|
||||
} from '~/services/api/batch';
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { BatchGeneration, BatchItem };
|
||||
|
||||
// Legacy interfaces for backward compatibility (snake_case)
|
||||
export interface BatchPrompt {
|
||||
text: string;
|
||||
negative_prompt?: string;
|
||||
|
|
@ -18,69 +33,53 @@ export interface SharedSettings {
|
|||
guidance_scale: number;
|
||||
}
|
||||
|
||||
export interface BatchGeneration {
|
||||
id: string;
|
||||
name: string;
|
||||
total_count: number;
|
||||
completed_count: number;
|
||||
failed_count: number;
|
||||
processing_count?: number;
|
||||
pending_count?: number;
|
||||
status: 'pending' | 'processing' | 'completed' | 'partial' | 'failed';
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
items?: BatchItem[];
|
||||
}
|
||||
|
||||
export interface BatchItem {
|
||||
id: string;
|
||||
index: number;
|
||||
prompt: string;
|
||||
status: string;
|
||||
error_message?: string;
|
||||
retry_count?: number;
|
||||
image_url?: string;
|
||||
interface PollingState {
|
||||
intervalId: ReturnType<typeof setInterval> | null;
|
||||
batchId: string;
|
||||
}
|
||||
|
||||
interface BatchStore {
|
||||
// State
|
||||
activeBatches: Map<string, BatchGeneration>;
|
||||
currentBatch: BatchGeneration | null;
|
||||
subscriptions: Map<string, RealtimeChannel>;
|
||||
|
||||
pollingStates: Map<string, PollingState>;
|
||||
|
||||
// UI State
|
||||
isBatchModalOpen: boolean;
|
||||
isCreatingBatch: boolean;
|
||||
|
||||
|
||||
// Actions
|
||||
createBatch: (prompts: BatchPrompt[], settings: SharedSettings, name?: string) => Promise<string>;
|
||||
loadBatch: (batchId: string) => Promise<void>;
|
||||
loadUserBatches: () => Promise<void>;
|
||||
|
||||
// Subscriptions
|
||||
subscribeToBatch: (batchId: string) => void;
|
||||
unsubscribeFromBatch: (batchId: string) => void;
|
||||
unsubscribeAll: () => void;
|
||||
|
||||
|
||||
// Polling (replaces Realtime subscriptions)
|
||||
startPolling: (batchId: string, intervalMs?: number) => void;
|
||||
stopPolling: (batchId: string) => void;
|
||||
stopAllPolling: () => void;
|
||||
|
||||
// Batch Actions
|
||||
retryFailed: (batchId: string) => Promise<void>;
|
||||
cancelBatch: (batchId: string) => Promise<void>;
|
||||
deleteBatch: (batchId: string) => Promise<void>;
|
||||
|
||||
|
||||
// UI Actions
|
||||
openBatchModal: () => void;
|
||||
closeBatchModal: () => void;
|
||||
setCurrentBatch: (batch: BatchGeneration | null) => void;
|
||||
|
||||
|
||||
// Cleanup
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// Default polling interval (2 seconds)
|
||||
const DEFAULT_POLL_INTERVAL = 2000;
|
||||
|
||||
export const useBatchStore = create<BatchStore>((set, get) => ({
|
||||
// Initial State
|
||||
activeBatches: new Map(),
|
||||
currentBatch: null,
|
||||
subscriptions: new Map(),
|
||||
pollingStates: new Map(),
|
||||
isBatchModalOpen: false,
|
||||
isCreatingBatch: false,
|
||||
|
||||
|
|
@ -89,56 +88,43 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
|
|||
set({ isCreatingBatch: true });
|
||||
|
||||
try {
|
||||
// Get the session to ensure we have a valid token
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) throw new Error('Not authenticated');
|
||||
// Convert snake_case inputs to camelCase for API
|
||||
const apiPrompts: BatchPromptDto[] = prompts.map(p => ({
|
||||
text: p.text,
|
||||
negativePrompt: p.negative_prompt,
|
||||
seed: p.seed,
|
||||
tags: p.tags,
|
||||
}));
|
||||
|
||||
// Call the batch-generate edge function with explicit auth header
|
||||
const response = await supabase.functions.invoke('batch-generate', {
|
||||
body: {
|
||||
prompts: prompts.map(p => ({
|
||||
text: p.text,
|
||||
negative_prompt: p.negative_prompt,
|
||||
seed: p.seed,
|
||||
tags: p.tags
|
||||
})),
|
||||
shared_settings: settings,
|
||||
batch_name: name
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
}
|
||||
const apiSettings: SharedSettingsDto = {
|
||||
modelId: settings.model_id,
|
||||
modelVersion: settings.model_version,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
steps: settings.steps,
|
||||
guidanceScale: settings.guidance_scale,
|
||||
};
|
||||
|
||||
const batch = await apiCreateBatch({
|
||||
prompts: apiPrompts,
|
||||
sharedSettings: apiSettings,
|
||||
batchName: name,
|
||||
});
|
||||
|
||||
if (response.error) throw response.error;
|
||||
|
||||
const { batch } = response.data;
|
||||
|
||||
// Add to active batches
|
||||
const newBatch: BatchGeneration = {
|
||||
id: batch.id,
|
||||
name: batch.name,
|
||||
total_count: batch.total_count,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
status: 'processing',
|
||||
created_at: new Date().toISOString(),
|
||||
items: batch.generations
|
||||
};
|
||||
|
||||
set(state => {
|
||||
const newBatches = new Map(state.activeBatches);
|
||||
newBatches.set(batch.id, newBatch);
|
||||
return {
|
||||
newBatches.set(batch.id, batch);
|
||||
return {
|
||||
activeBatches: newBatches,
|
||||
currentBatch: newBatch,
|
||||
currentBatch: batch,
|
||||
isCreatingBatch: false
|
||||
};
|
||||
});
|
||||
|
||||
// Subscribe to updates
|
||||
get().subscribeToBatch(batch.id);
|
||||
|
||||
|
||||
// Start polling for updates
|
||||
get().startPolling(batch.id);
|
||||
|
||||
return batch.id;
|
||||
} catch (error) {
|
||||
console.error('Error creating batch:', error);
|
||||
|
|
@ -150,36 +136,17 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
|
|||
// Load a specific batch
|
||||
loadBatch: async (batchId) => {
|
||||
try {
|
||||
const { data: batch, error } = await supabase
|
||||
.from('batch_progress')
|
||||
.select('*')
|
||||
.eq('id', batchId)
|
||||
.single();
|
||||
const batch = await apiGetBatch(batchId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const batchData: BatchGeneration = {
|
||||
id: batch.id,
|
||||
name: batch.name,
|
||||
total_count: batch.total_count,
|
||||
completed_count: batch.completed_count,
|
||||
failed_count: batch.failed_count,
|
||||
processing_count: batch.processing_count,
|
||||
pending_count: batch.pending_count,
|
||||
status: batch.status,
|
||||
created_at: batch.created_at,
|
||||
items: batch.items
|
||||
};
|
||||
|
||||
set(state => {
|
||||
const newBatches = new Map(state.activeBatches);
|
||||
newBatches.set(batchId, batchData);
|
||||
return {
|
||||
newBatches.set(batchId, batch);
|
||||
return {
|
||||
activeBatches: newBatches,
|
||||
currentBatch: batchData
|
||||
currentBatch: batch
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading batch:', error);
|
||||
throw error;
|
||||
|
|
@ -189,150 +156,117 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
|
|||
// Load all user batches
|
||||
loadUserBatches: async () => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
const batches = await apiGetUserBatches();
|
||||
|
||||
const { data: batches, error } = await supabase
|
||||
.from('batch_generations')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
set(state => {
|
||||
const newBatches = new Map(state.activeBatches);
|
||||
batches?.forEach(batch => {
|
||||
newBatches.set(batch.id, {
|
||||
id: batch.id,
|
||||
name: batch.name,
|
||||
total_count: batch.total_count,
|
||||
completed_count: batch.completed_count,
|
||||
failed_count: batch.failed_count,
|
||||
status: batch.status,
|
||||
created_at: batch.created_at,
|
||||
completed_at: batch.completed_at
|
||||
});
|
||||
batches.forEach(batch => {
|
||||
newBatches.set(batch.id, batch);
|
||||
});
|
||||
return { activeBatches: newBatches };
|
||||
});
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading batches:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Subscribe to batch updates
|
||||
subscribeToBatch: (batchId) => {
|
||||
// Start polling for batch updates
|
||||
startPolling: (batchId, intervalMs = DEFAULT_POLL_INTERVAL) => {
|
||||
const state = get();
|
||||
|
||||
// Don't subscribe if already subscribed
|
||||
if (state.subscriptions.has(batchId)) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`batch_${batchId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'batch_generations',
|
||||
filter: `id=eq.${batchId}`
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Batch update:', payload);
|
||||
if (payload.new) {
|
||||
set(state => {
|
||||
const newBatches = new Map(state.activeBatches);
|
||||
const existing = newBatches.get(batchId);
|
||||
if (existing) {
|
||||
newBatches.set(batchId, {
|
||||
...existing,
|
||||
...payload.new,
|
||||
});
|
||||
}
|
||||
return { activeBatches: newBatches };
|
||||
|
||||
// Don't start if already polling this batch
|
||||
if (state.pollingStates.has(batchId)) return;
|
||||
|
||||
const pollBatch = async () => {
|
||||
try {
|
||||
const progress = await apiGetBatchProgress(batchId);
|
||||
|
||||
set(state => {
|
||||
const newBatches = new Map(state.activeBatches);
|
||||
const existing = newBatches.get(batchId);
|
||||
if (existing) {
|
||||
newBatches.set(batchId, {
|
||||
...existing,
|
||||
totalCount: progress.totalCount,
|
||||
completedCount: progress.completedCount,
|
||||
failedCount: progress.failedCount,
|
||||
processingCount: progress.processingCount,
|
||||
pendingCount: progress.pendingCount,
|
||||
status: progress.status as BatchGeneration['status'],
|
||||
});
|
||||
|
||||
// Update currentBatch if it's this batch
|
||||
const updatedBatch = newBatches.get(batchId);
|
||||
if (state.currentBatch?.id === batchId && updatedBatch) {
|
||||
return {
|
||||
activeBatches: newBatches,
|
||||
currentBatch: updatedBatch
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'image_generations',
|
||||
filter: `batch_id=eq.${batchId}`
|
||||
},
|
||||
async (payload) => {
|
||||
console.log('Generation update:', payload);
|
||||
// Reload the batch to get updated items
|
||||
return { activeBatches: newBatches };
|
||||
});
|
||||
|
||||
// Stop polling if batch is complete or failed
|
||||
if (progress.status === 'completed' || progress.status === 'failed' || progress.status === 'partial') {
|
||||
// Load full batch details one more time
|
||||
await get().loadBatch(batchId);
|
||||
get().stopPolling(batchId);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error polling batch:', error);
|
||||
// Stop polling on error to prevent spamming
|
||||
get().stopPolling(batchId);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll immediately, then at interval
|
||||
pollBatch();
|
||||
const intervalId = setInterval(pollBatch, intervalMs);
|
||||
|
||||
set(state => {
|
||||
const newSubs = new Map(state.subscriptions);
|
||||
newSubs.set(batchId, channel);
|
||||
return { subscriptions: newSubs };
|
||||
const newPolling = new Map(state.pollingStates);
|
||||
newPolling.set(batchId, { intervalId, batchId });
|
||||
return { pollingStates: newPolling };
|
||||
});
|
||||
},
|
||||
|
||||
// Unsubscribe from batch updates
|
||||
unsubscribeFromBatch: (batchId) => {
|
||||
// Stop polling for a specific batch
|
||||
stopPolling: (batchId) => {
|
||||
const state = get();
|
||||
const channel = state.subscriptions.get(batchId);
|
||||
|
||||
if (channel) {
|
||||
channel.unsubscribe();
|
||||
const pollingState = state.pollingStates.get(batchId);
|
||||
|
||||
if (pollingState?.intervalId) {
|
||||
clearInterval(pollingState.intervalId);
|
||||
set(state => {
|
||||
const newSubs = new Map(state.subscriptions);
|
||||
newSubs.delete(batchId);
|
||||
return { subscriptions: newSubs };
|
||||
const newPolling = new Map(state.pollingStates);
|
||||
newPolling.delete(batchId);
|
||||
return { pollingStates: newPolling };
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Unsubscribe from all
|
||||
unsubscribeAll: () => {
|
||||
// Stop all polling
|
||||
stopAllPolling: () => {
|
||||
const state = get();
|
||||
state.subscriptions.forEach(channel => channel.unsubscribe());
|
||||
set({ subscriptions: new Map() });
|
||||
state.pollingStates.forEach(pollingState => {
|
||||
if (pollingState.intervalId) {
|
||||
clearInterval(pollingState.intervalId);
|
||||
}
|
||||
});
|
||||
set({ pollingStates: new Map() });
|
||||
},
|
||||
|
||||
// Retry failed generations in a batch
|
||||
retryFailed: async (batchId) => {
|
||||
try {
|
||||
// Reset failed generations to pending
|
||||
const { error } = await supabase
|
||||
.from('image_generations')
|
||||
.update({
|
||||
status: 'pending',
|
||||
error_message: null,
|
||||
retry_count: 0
|
||||
})
|
||||
.eq('batch_id', batchId)
|
||||
.eq('status', 'failed');
|
||||
await apiRetryFailed(batchId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Update batch status
|
||||
await supabase
|
||||
.from('batch_generations')
|
||||
.update({
|
||||
status: 'processing',
|
||||
failed_count: 0
|
||||
})
|
||||
.eq('id', batchId);
|
||||
|
||||
// Trigger queue processing
|
||||
await supabase.functions.invoke('process-queue');
|
||||
|
||||
// Reload batch
|
||||
// Reload batch and restart polling
|
||||
await get().loadBatch(batchId);
|
||||
|
||||
get().startPolling(batchId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error retrying batch:', error);
|
||||
throw error;
|
||||
|
|
@ -342,25 +276,12 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
|
|||
// Cancel a batch
|
||||
cancelBatch: async (batchId) => {
|
||||
try {
|
||||
// Update pending generations to cancelled
|
||||
await supabase
|
||||
.from('image_generations')
|
||||
.update({ status: 'failed', error_message: 'Cancelled by user' })
|
||||
.eq('batch_id', batchId)
|
||||
.in('status', ['pending']);
|
||||
await apiCancelBatch(batchId);
|
||||
|
||||
// Update batch status
|
||||
await supabase
|
||||
.from('batch_generations')
|
||||
.update({
|
||||
status: 'failed',
|
||||
completed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', batchId);
|
||||
|
||||
// Reload batch
|
||||
// Stop polling and reload batch
|
||||
get().stopPolling(batchId);
|
||||
await get().loadBatch(batchId);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error cancelling batch:', error);
|
||||
throw error;
|
||||
|
|
@ -370,27 +291,20 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
|
|||
// Delete a batch and all its generations
|
||||
deleteBatch: async (batchId) => {
|
||||
try {
|
||||
// Delete will cascade to image_generations
|
||||
const { error } = await supabase
|
||||
.from('batch_generations')
|
||||
.delete()
|
||||
.eq('id', batchId);
|
||||
await apiDeleteBatch(batchId);
|
||||
|
||||
// Stop polling and remove from state
|
||||
get().stopPolling(batchId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Remove from state
|
||||
set(state => {
|
||||
const newBatches = new Map(state.activeBatches);
|
||||
newBatches.delete(batchId);
|
||||
return {
|
||||
return {
|
||||
activeBatches: newBatches,
|
||||
currentBatch: state.currentBatch?.id === batchId ? null : state.currentBatch
|
||||
};
|
||||
});
|
||||
|
||||
// Unsubscribe
|
||||
get().unsubscribeFromBatch(batchId);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting batch:', error);
|
||||
throw error;
|
||||
|
|
@ -404,13 +318,13 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
|
|||
|
||||
// Reset store
|
||||
reset: () => {
|
||||
get().unsubscribeAll();
|
||||
get().stopAllPolling();
|
||||
set({
|
||||
activeBatches: new Map(),
|
||||
currentBatch: null,
|
||||
subscriptions: new Map(),
|
||||
pollingStates: new Map(),
|
||||
isBatchModalOpen: false,
|
||||
isCreatingBatch: false
|
||||
});
|
||||
}
|
||||
}));
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue