From 3af9d32aa590590c945235b476c05514781c46e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Tue, 10 Feb 2026 11:56:48 +0100 Subject: [PATCH 01/27] Add PhotoBuilder vertical: domain layer, infrastructure adapters, and unit tests New vertical for AI image generation matching web page content: - Domain: PhotoSession/PhotoImage entities, enums, PhotoBuilderService with IMAGE_COUNT constant - Infrastructure: PromptGenerator (NeuronAI agent with deliver_image_prompt tool), ImageGenerator (OpenAI Images API), GeneratedImageStorage, Messenger messages/handlers - Tests: 45 unit tests covering entities, service logic, storage, and image generator Co-authored-by: Cursor --- src/PhotoBuilder/Domain/Entity/PhotoImage.php | 177 ++++++++++++++++ .../Domain/Entity/PhotoSession.php | 195 +++++++++++++++++ .../Domain/Enum/PhotoImageStatus.php | 13 ++ .../Domain/Enum/PhotoSessionStatus.php | 14 ++ .../Domain/Service/PhotoBuilderService.php | 110 ++++++++++ .../Adapter/ImageGeneratorInterface.php | 18 ++ .../Adapter/ImagePromptAgent.php | 137 ++++++++++++ .../Adapter/OpenAiImageGenerator.php | 71 +++++++ .../Adapter/OpenAiPromptGenerator.php | 56 +++++ .../Adapter/PromptGeneratorInterface.php | 23 ++ .../Handler/GenerateImageHandler.php | 98 +++++++++ .../Handler/GenerateImagePromptsHandler.php | 99 +++++++++ .../Message/GenerateImageMessage.php | 16 ++ .../Message/GenerateImagePromptsMessage.php | 17 ++ .../Storage/GeneratedImageStorage.php | 86 ++++++++ .../GeneratedImageStorageTest.php | 94 +++++++++ .../PhotoBuilder/OpenAiImageGeneratorTest.php | 69 ++++++ .../PhotoBuilder/PhotoBuilderServiceTest.php | 199 ++++++++++++++++++ tests/Unit/PhotoBuilder/PhotoImageTest.php | 133 ++++++++++++ tests/Unit/PhotoBuilder/PhotoSessionTest.php | 141 +++++++++++++ 20 files changed, 1766 insertions(+) create mode 100644 src/PhotoBuilder/Domain/Entity/PhotoImage.php create mode 100644 src/PhotoBuilder/Domain/Entity/PhotoSession.php create mode 100644 src/PhotoBuilder/Domain/Enum/PhotoImageStatus.php create mode 100644 src/PhotoBuilder/Domain/Enum/PhotoSessionStatus.php create mode 100644 src/PhotoBuilder/Domain/Service/PhotoBuilderService.php create mode 100644 src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorInterface.php create mode 100644 src/PhotoBuilder/Infrastructure/Adapter/ImagePromptAgent.php create mode 100644 src/PhotoBuilder/Infrastructure/Adapter/OpenAiImageGenerator.php create mode 100644 src/PhotoBuilder/Infrastructure/Adapter/OpenAiPromptGenerator.php create mode 100644 src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php create mode 100644 src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php create mode 100644 src/PhotoBuilder/Infrastructure/Handler/GenerateImagePromptsHandler.php create mode 100644 src/PhotoBuilder/Infrastructure/Message/GenerateImageMessage.php create mode 100644 src/PhotoBuilder/Infrastructure/Message/GenerateImagePromptsMessage.php create mode 100644 src/PhotoBuilder/Infrastructure/Storage/GeneratedImageStorage.php create mode 100644 tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php create mode 100644 tests/Unit/PhotoBuilder/OpenAiImageGeneratorTest.php create mode 100644 tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php create mode 100644 tests/Unit/PhotoBuilder/PhotoImageTest.php create mode 100644 tests/Unit/PhotoBuilder/PhotoSessionTest.php diff --git a/src/PhotoBuilder/Domain/Entity/PhotoImage.php b/src/PhotoBuilder/Domain/Entity/PhotoImage.php new file mode 100644 index 0000000..ffbb283 --- /dev/null +++ b/src/PhotoBuilder/Domain/Entity/PhotoImage.php @@ -0,0 +1,177 @@ +session = $session; + $this->position = $position; + $this->status = PhotoImageStatus::Pending; + $this->createdAt = DateAndTimeService::getDateTimeImmutable(); + + $session->addImage($this); + } + + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: UuidGenerator::class)] + #[ORM\Column( + type: Types::GUID, + unique: true + )] + private ?string $id = null; + + public function getId(): ?string + { + return $this->id; + } + + #[ORM\ManyToOne( + targetEntity: PhotoSession::class, + inversedBy: 'images' + )] + #[ORM\JoinColumn( + nullable: false, + onDelete: 'CASCADE' + )] + private readonly PhotoSession $session; + + public function getSession(): PhotoSession + { + return $this->session; + } + + #[ORM\Column( + type: Types::INTEGER, + nullable: false + )] + private readonly int $position; + + public function getPosition(): int + { + return $this->position; + } + + #[ORM\Column( + type: Types::TEXT, + nullable: true + )] + private ?string $prompt = null; + + public function getPrompt(): ?string + { + return $this->prompt; + } + + public function setPrompt(?string $prompt): void + { + $this->prompt = $prompt; + } + + #[ORM\Column( + type: Types::STRING, + length: 512, + nullable: true + )] + private ?string $suggestedFileName = null; + + public function getSuggestedFileName(): ?string + { + return $this->suggestedFileName; + } + + public function setSuggestedFileName(?string $suggestedFileName): void + { + $this->suggestedFileName = $suggestedFileName; + } + + #[ORM\Column( + type: Types::STRING, + length: 32, + nullable: false, + enumType: PhotoImageStatus::class + )] + private PhotoImageStatus $status; + + public function getStatus(): PhotoImageStatus + { + return $this->status; + } + + public function setStatus(PhotoImageStatus $status): void + { + $this->status = $status; + } + + #[ORM\Column( + type: Types::STRING, + length: 1024, + nullable: true + )] + private ?string $storagePath = null; + + public function getStoragePath(): ?string + { + return $this->storagePath; + } + + public function setStoragePath(?string $storagePath): void + { + $this->storagePath = $storagePath; + } + + #[ORM\Column( + type: Types::TEXT, + nullable: true + )] + private ?string $errorMessage = null; + + public function getErrorMessage(): ?string + { + return $this->errorMessage; + } + + public function setErrorMessage(?string $errorMessage): void + { + $this->errorMessage = $errorMessage; + } + + #[ORM\Column( + type: Types::DATETIME_IMMUTABLE, + nullable: false + )] + private readonly DateTimeImmutable $createdAt; + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * Whether this image is in a terminal state (completed or failed). + */ + public function isTerminal(): bool + { + return $this->status === PhotoImageStatus::Completed + || $this->status === PhotoImageStatus::Failed; + } +} diff --git a/src/PhotoBuilder/Domain/Entity/PhotoSession.php b/src/PhotoBuilder/Domain/Entity/PhotoSession.php new file mode 100644 index 0000000..2cdaa7a --- /dev/null +++ b/src/PhotoBuilder/Domain/Entity/PhotoSession.php @@ -0,0 +1,195 @@ +workspaceId = $workspaceId; + $this->conversationId = $conversationId; + $this->pagePath = $pagePath; + $this->userPrompt = $userPrompt; + $this->status = PhotoSessionStatus::GeneratingPrompts; + $this->createdAt = DateAndTimeService::getDateTimeImmutable(); + $this->images = new ArrayCollection(); + } + + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: UuidGenerator::class)] + #[ORM\Column( + type: Types::GUID, + unique: true + )] + private ?string $id = null; + + public function getId(): ?string + { + return $this->id; + } + + #[ORM\Column( + type: Types::GUID, + nullable: false + )] + private readonly string $workspaceId; + + public function getWorkspaceId(): string + { + return $this->workspaceId; + } + + #[ORM\Column( + type: Types::GUID, + nullable: false + )] + private readonly string $conversationId; + + public function getConversationId(): string + { + return $this->conversationId; + } + + #[ORM\Column( + type: Types::STRING, + length: 512, + nullable: false + )] + private readonly string $pagePath; + + public function getPagePath(): string + { + return $this->pagePath; + } + + #[ORM\Column( + type: Types::TEXT, + nullable: false + )] + private string $userPrompt; + + public function getUserPrompt(): string + { + return $this->userPrompt; + } + + public function setUserPrompt(string $userPrompt): void + { + $this->userPrompt = $userPrompt; + } + + #[ORM\Column( + type: Types::STRING, + length: 32, + nullable: false, + enumType: PhotoSessionStatus::class + )] + private PhotoSessionStatus $status; + + public function getStatus(): PhotoSessionStatus + { + return $this->status; + } + + public function setStatus(PhotoSessionStatus $status): void + { + $this->status = $status; + } + + #[ORM\Column( + type: Types::DATETIME_IMMUTABLE, + nullable: false + )] + private readonly DateTimeImmutable $createdAt; + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + /** + * @var Collection + */ + #[ORM\OneToMany( + targetEntity: PhotoImage::class, + mappedBy: 'session', + cascade: ['persist', 'remove'], + orphanRemoval: true + )] + #[ORM\OrderBy(['position' => 'ASC'])] + private Collection $images; + + /** + * @return Collection + */ + public function getImages(): Collection + { + return $this->images; + } + + public function addImage(PhotoImage $image): void + { + if (!$this->images->contains($image)) { + $this->images->add($image); + } + } + + /** + * Check whether all images in the session have reached a terminal state. + */ + public function areAllImagesTerminal(): bool + { + if ($this->images->isEmpty()) { + return false; + } + + foreach ($this->images as $image) { + if (!$image->isTerminal()) { + return false; + } + } + + return true; + } + + /** + * Check whether all images completed successfully. + */ + public function areAllImagesCompleted(): bool + { + if ($this->images->isEmpty()) { + return false; + } + + foreach ($this->images as $image) { + if ($image->getStatus() !== PhotoImageStatus::Completed) { + return false; + } + } + + return true; + } +} diff --git a/src/PhotoBuilder/Domain/Enum/PhotoImageStatus.php b/src/PhotoBuilder/Domain/Enum/PhotoImageStatus.php new file mode 100644 index 0000000..761d8d6 --- /dev/null +++ b/src/PhotoBuilder/Domain/Enum/PhotoImageStatus.php @@ -0,0 +1,13 @@ +entityManager->persist($session); + $this->entityManager->flush(); + + return $session; + } + + /** + * Update image prompts from LLM-generated results. + * + * @param list $promptResults + * @param list $keepImageIds Image IDs whose prompts should not be updated + * + * @return list Images whose prompts were actually changed + */ + public function updateImagePrompts( + PhotoSession $session, + array $promptResults, + array $keepImageIds = [], + ): array { + $changedImages = []; + $images = $session->getImages()->toArray(); + + usort($images, static fn (PhotoImage $a, PhotoImage $b) => $a->getPosition() <=> $b->getPosition()); + + foreach ($images as $index => $image) { + if (in_array($image->getId(), $keepImageIds, true)) { + continue; + } + + if (!isset($promptResults[$index])) { + continue; + } + + $image->setPrompt($promptResults[$index]['prompt']); + $image->setSuggestedFileName($promptResults[$index]['fileName']); + $image->setStatus(PhotoImageStatus::Pending); + $image->setStoragePath(null); + $image->setErrorMessage(null); + + $changedImages[] = $image; + } + + return $changedImages; + } + + /** + * Transition session status to images_ready if all images are in terminal state, + * or to failed if any image failed and all are terminal. + */ + public function updateSessionStatusFromImages(PhotoSession $session): void + { + if (!$session->areAllImagesTerminal()) { + return; + } + + if ($session->areAllImagesCompleted()) { + $session->setStatus(PhotoSessionStatus::ImagesReady); + } else { + // At least one image failed, but all are terminal + $session->setStatus(PhotoSessionStatus::ImagesReady); + } + + $this->entityManager->flush(); + } +} diff --git a/src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorInterface.php b/src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorInterface.php new file mode 100644 index 0000000..545ca0f --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorInterface.php @@ -0,0 +1,18 @@ + */ + private array $collectedPrompts = []; + + public function __construct( + private readonly string $apiKey, + private readonly string $pageHtml, + private readonly int $imageCount, + private readonly string $model = 'gpt-5.2', + private readonly ?HandlerStack $guzzleHandlerStack = null, + ) { + } + + protected function provider(): AIProviderInterface + { + $httpOptions = null; + + if ($this->guzzleHandlerStack !== null) { + $httpOptions = new HttpClientOptions( + null, + null, + null, + $this->guzzleHandlerStack, + ); + } + + return new OpenAI( + $this->apiKey, + $this->model, + [], + false, + $httpOptions, + ); + } + + public function instructions(): string + { + return sprintf( + 'You are a friendly AI assistant that helps the user to generate %d prompts ' + . 'that each will be fed into an LLM-backed AI image generation agent, in order to ' + . 'generate images that shall be used on a web page with the following contents:' + . "\n\n%s\n\n" + . 'Think about what each of the %d images should show in order to optimally fit ' + . 'the narrative of the web page content.' + . "\n\n" + . 'For each image, call the deliver_image_prompt tool with:' + . "\n- A detailed, descriptive prompt suitable for an AI image generation model" + . "\n- A descriptive, kebab-case filename (with .jpg extension) that clearly describes " + . 'what the image shows (e.g. "modern-office-team-collaborating.jpg", not "office.jpg" ' + . 'or "image1.jpg")', + $this->imageCount, + $this->pageHtml, + $this->imageCount, + ); + } + + /** + * @return list<\NeuronAI\Tools\ToolInterface> + */ + protected function tools(): array + { + return [ + Tool::make( + 'deliver_image_prompt', + 'Deliver a single image generation prompt with a descriptive filename. ' + . 'Call this tool once per image.', + ) + ->addProperty( + new ToolProperty( + 'prompt', + PropertyType::STRING, + 'A detailed, descriptive prompt for AI image generation.', + true + ) + ) + ->addProperty( + new ToolProperty( + 'file_name', + PropertyType::STRING, + 'A descriptive, kebab-case filename with .jpg extension (e.g. "cozy-cafe-winter-scene.jpg").', + true + ) + ) + ->setCallable(function (string $prompt, string $file_name): string { + $this->collectedPrompts[] = [ + 'prompt' => $prompt, + 'fileName' => $file_name, + ]; + + return 'Prompt delivered successfully.'; + }), + ]; + } + + /** + * Get all prompts collected via tool calls. + * + * @return list + */ + public function getCollectedPrompts(): array + { + return $this->collectedPrompts; + } + + /** + * Reset collected prompts (useful for re-runs). + */ + public function resetCollectedPrompts(): void + { + $this->collectedPrompts = []; + } +} diff --git a/src/PhotoBuilder/Infrastructure/Adapter/OpenAiImageGenerator.php b/src/PhotoBuilder/Infrastructure/Adapter/OpenAiImageGenerator.php new file mode 100644 index 0000000..adaf8de --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/OpenAiImageGenerator.php @@ -0,0 +1,71 @@ +httpClient->request('POST', self::API_URL, [ + 'headers' => [ + 'Authorization' => sprintf('Bearer %s', $apiKey), + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'model' => self::MODEL, + 'prompt' => $prompt, + 'n' => 1, + 'size' => self::IMAGE_SIZE, + 'response_format' => 'b64_json', + ], + ]); + + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200) { + throw new RuntimeException(sprintf( + 'OpenAI image generation API returned status %d: %s', + $statusCode, + $response->getContent(false), + )); + } + + /** @var array{data: list} $result */ + $result = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + if (!isset($result['data'][0]['b64_json'])) { + throw new RuntimeException('OpenAI image generation API returned unexpected response structure.'); + } + + $imageData = base64_decode($result['data'][0]['b64_json'], true); + + if ($imageData === false) { + throw new RuntimeException('Failed to decode base64 image data from OpenAI response.'); + } + + return $imageData; + } +} diff --git a/src/PhotoBuilder/Infrastructure/Adapter/OpenAiPromptGenerator.php b/src/PhotoBuilder/Infrastructure/Adapter/OpenAiPromptGenerator.php new file mode 100644 index 0000000..f210bf8 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/OpenAiPromptGenerator.php @@ -0,0 +1,56 @@ + + */ + public function generatePrompts( + string $pageHtml, + string $userPrompt, + string $apiKey, + int $count, + ): array { + $agent = new ImagePromptAgent( + $apiKey, + $pageHtml, + $count, + 'gpt-5.2', + $this->guzzleHandlerStack, + ); + + $agent->chat(new UserMessage($userPrompt)); + + $prompts = $agent->getCollectedPrompts(); + + if (count($prompts) < $count) { + throw new RuntimeException(sprintf( + 'Expected %d image prompts but the agent delivered only %d.', + $count, + count($prompts), + )); + } + + // Return only the expected count (in case the agent delivered more) + return array_slice($prompts, 0, $count); + } +} diff --git a/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php b/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php new file mode 100644 index 0000000..8c1a5cc --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php @@ -0,0 +1,23 @@ + + */ + public function generatePrompts( + string $pageHtml, + string $userPrompt, + string $apiKey, + int $count, + ): array; +} diff --git a/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php b/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php new file mode 100644 index 0000000..6a49225 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php @@ -0,0 +1,98 @@ +entityManager->find(PhotoImage::class, $message->imageId); + + if ($image === null) { + $this->logger->error('PhotoImage not found', ['imageId' => $message->imageId]); + + return; + } + + $session = $image->getSession(); + + try { + $image->setStatus(PhotoImageStatus::Generating); + $this->entityManager->flush(); + + // Get API key + $workspace = $this->workspaceMgmtFacade->getWorkspaceById($session->getWorkspaceId()); + $project = $workspace !== null ? $this->projectMgmtFacade->getProjectInfo($workspace->projectId) : null; + + if ($project === null || $project->llmApiKey === '') { + $image->setStatus(PhotoImageStatus::Failed); + $image->setErrorMessage('No LLM API key configured for project.'); + $this->entityManager->flush(); + $this->updateSessionStatus($session); + + return; + } + + // Generate image + $imageData = $this->imageGenerator->generateImage( + $image->getPrompt() ?? '', + $project->llmApiKey, + ); + + // Store on disk + $storagePath = $this->imageStorage->save( + $session->getId() ?? '', + $image->getPosition(), + $imageData, + ); + + $image->setStoragePath($storagePath); + $image->setStatus(PhotoImageStatus::Completed); + $this->entityManager->flush(); + } catch (Throwable $e) { + $this->logger->error('Failed to generate image', [ + 'imageId' => $message->imageId, + 'error' => $e->getMessage(), + ]); + + $image->setStatus(PhotoImageStatus::Failed); + $image->setErrorMessage('Image generation failed: ' . $e->getMessage()); + $this->entityManager->flush(); + } + + $this->updateSessionStatus($session); + } + + private function updateSessionStatus(PhotoSession $session): void + { + $this->photoBuilderService->updateSessionStatusFromImages($session); + } +} diff --git a/src/PhotoBuilder/Infrastructure/Handler/GenerateImagePromptsHandler.php b/src/PhotoBuilder/Infrastructure/Handler/GenerateImagePromptsHandler.php new file mode 100644 index 0000000..5404ead --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Handler/GenerateImagePromptsHandler.php @@ -0,0 +1,99 @@ +entityManager->find(PhotoSession::class, $message->sessionId); + + if ($session === null) { + $this->logger->error('PhotoSession not found', ['sessionId' => $message->sessionId]); + + return; + } + + try { + // Load workspace and project info to get API key + $workspace = $this->workspaceMgmtFacade->getWorkspaceById($session->getWorkspaceId()); + $project = $workspace !== null ? $this->projectMgmtFacade->getProjectInfo($workspace->projectId) : null; + + if ($project === null || $project->llmApiKey === '') { + $this->logger->error('No LLM API key configured for project', [ + 'sessionId' => $message->sessionId, + 'workspaceId' => $session->getWorkspaceId(), + ]); + + $session->setStatus(PhotoSessionStatus::Failed); + $this->entityManager->flush(); + + return; + } + + // Read page HTML from the dist/ directory + $pagePath = 'dist/' . $session->getPagePath(); + $pageHtml = $this->workspaceMgmtFacade->readWorkspaceFile($session->getWorkspaceId(), $pagePath); + + // Generate prompts via LLM + $promptResults = $this->promptGenerator->generatePrompts( + $pageHtml, + $session->getUserPrompt(), + $project->llmApiKey, + PhotoBuilderService::IMAGE_COUNT, + ); + + // Update image entities with generated prompts + $this->photoBuilderService->updateImagePrompts($session, $promptResults); + + $session->setStatus(PhotoSessionStatus::PromptsReady); + $this->entityManager->flush(); + + // Dispatch image generation for each image + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $this->entityManager->flush(); + + foreach ($session->getImages() as $image) { + if ($image->getPrompt() !== null && $image->getPrompt() !== '') { + $this->messageBus->dispatch(new GenerateImageMessage($image->getId())); + } + } + } catch (Throwable $e) { + $this->logger->error('Failed to generate image prompts', [ + 'sessionId' => $message->sessionId, + 'error' => $e->getMessage(), + ]); + + $session->setStatus(PhotoSessionStatus::Failed); + $this->entityManager->flush(); + } + } +} diff --git a/src/PhotoBuilder/Infrastructure/Message/GenerateImageMessage.php b/src/PhotoBuilder/Infrastructure/Message/GenerateImageMessage.php new file mode 100644 index 0000000..d731ab1 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Message/GenerateImageMessage.php @@ -0,0 +1,16 @@ +baseDir . '/' . $relativePath; + + $dir = dirname($absolutePath); + if (!is_dir($dir)) { + mkdir($dir, 0o755, true); + } + + file_put_contents($absolutePath, $imageData); + + return $relativePath; + } + + /** + * Read image data from disk. + * + * @throws RuntimeException If the file does not exist + */ + public function read(string $storagePath): string + { + $absolutePath = $this->getAbsolutePath($storagePath); + + if (!file_exists($absolutePath)) { + throw new RuntimeException(sprintf('Generated image not found: %s', $storagePath)); + } + + $data = file_get_contents($absolutePath); + + if ($data === false) { + throw new RuntimeException(sprintf('Failed to read generated image: %s', $storagePath)); + } + + return $data; + } + + /** + * Get the absolute filesystem path for a relative storage path. + */ + public function getAbsolutePath(string $storagePath): string + { + return $this->baseDir . '/' . $storagePath; + } + + /** + * Check whether a stored image file exists. + */ + public function exists(string $storagePath): bool + { + return file_exists($this->getAbsolutePath($storagePath)); + } +} diff --git a/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php b/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php new file mode 100644 index 0000000..b0f52c3 --- /dev/null +++ b/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php @@ -0,0 +1,94 @@ +baseDir = sys_get_temp_dir() . '/photo-builder-test-' . uniqid(); + $this->storage = new GeneratedImageStorage($this->baseDir); + }); + + afterEach(function (): void { + // Clean up temp directory + if (is_dir($this->baseDir)) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->baseDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($this->baseDir); + } + }); + + describe('save', function (): void { + it('saves image data and returns relative path', function (): void { + $imageData = 'fake-png-data'; + $storagePath = $this->storage->save('session-123', 0, $imageData); + + expect($storagePath)->toBe('session-123/0.png'); + + $absolutePath = $this->baseDir . '/' . $storagePath; + expect(file_exists($absolutePath))->toBeTrue() + ->and(file_get_contents($absolutePath))->toBe('fake-png-data'); + }); + + it('creates directory structure if not exists', function (): void { + $this->storage->save('new-session', 3, 'data'); + + $dir = $this->baseDir . '/new-session'; + expect(is_dir($dir))->toBeTrue(); + }); + + it('handles multiple positions in same session', function (): void { + $this->storage->save('session-abc', 0, 'image-0'); + $this->storage->save('session-abc', 1, 'image-1'); + $this->storage->save('session-abc', 4, 'image-4'); + + expect(file_get_contents($this->baseDir . '/session-abc/0.png'))->toBe('image-0') + ->and(file_get_contents($this->baseDir . '/session-abc/1.png'))->toBe('image-1') + ->and(file_get_contents($this->baseDir . '/session-abc/4.png'))->toBe('image-4'); + }); + }); + + describe('read', function (): void { + it('reads saved image data', function (): void { + $this->storage->save('session-123', 0, 'my-image-data'); + + $data = $this->storage->read('session-123/0.png'); + expect($data)->toBe('my-image-data'); + }); + + it('throws exception for non-existent file', function (): void { + expect(fn () => $this->storage->read('nonexistent/0.png')) + ->toThrow(RuntimeException::class); + }); + }); + + describe('getAbsolutePath', function (): void { + it('returns absolute path for a relative storage path', function (): void { + $absolute = $this->storage->getAbsolutePath('session-123/0.png'); + expect($absolute)->toBe($this->baseDir . '/session-123/0.png'); + }); + }); + + describe('exists', function (): void { + it('returns true for existing file', function (): void { + $this->storage->save('session-123', 0, 'data'); + expect($this->storage->exists('session-123/0.png'))->toBeTrue(); + }); + + it('returns false for non-existing file', function (): void { + expect($this->storage->exists('nonexistent/0.png'))->toBeFalse(); + }); + }); +}); diff --git a/tests/Unit/PhotoBuilder/OpenAiImageGeneratorTest.php b/tests/Unit/PhotoBuilder/OpenAiImageGeneratorTest.php new file mode 100644 index 0000000..419359e --- /dev/null +++ b/tests/Unit/PhotoBuilder/OpenAiImageGeneratorTest.php @@ -0,0 +1,69 @@ + [['b64_json' => $fakeB64]]]); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->willReturn($responsePayload); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects($this->once()) + ->method('request') + ->with( + 'POST', + 'https://api.openai.com/v1/images/generations', + $this->callback(function (array $options) { + return $options['json']['model'] === 'gpt-image-1' + && $options['json']['response_format'] === 'b64_json' + && $options['json']['prompt'] === 'A beautiful sunset' + && str_contains($options['headers']['Authorization'], 'Bearer test-key'); + }) + ) + ->willReturn($response); + + $generator = new OpenAiImageGenerator($httpClient); + $result = $generator->generateImage('A beautiful sunset', 'test-key'); + + expect($result)->toBe($fakeImageData); + }); + + it('throws exception on non-200 status', function (): void { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(429); + $response->method('getContent')->willReturn('Rate limit exceeded'); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $generator = new OpenAiImageGenerator($httpClient); + + expect(fn () => $generator->generateImage('prompt', 'key')) + ->toThrow(RuntimeException::class, 'status 429'); + }); + + it('throws exception on missing data in response', function (): void { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->willReturn(json_encode(['data' => []])); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $generator = new OpenAiImageGenerator($httpClient); + + expect(fn () => $generator->generateImage('prompt', 'key')) + ->toThrow(RuntimeException::class, 'unexpected response structure'); + }); + }); +}); diff --git a/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php b/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php new file mode 100644 index 0000000..73e7636 --- /dev/null +++ b/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php @@ -0,0 +1,199 @@ +toBe(5); + }); + }); + + describe('createSession', function (): void { + it('creates a session with IMAGE_COUNT images', function (): void { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects($this->once())->method('persist'); + $em->expects($this->once())->method('flush'); + + $service = new PhotoBuilderService($em); + $session = $service->createSession('ws-123', 'conv-456', 'index.html', 'Generate images'); + + expect($session->getWorkspaceId())->toBe('ws-123') + ->and($session->getConversationId())->toBe('conv-456') + ->and($session->getPagePath())->toBe('index.html') + ->and($session->getUserPrompt())->toBe('Generate images') + ->and($session->getStatus())->toBe(PhotoSessionStatus::GeneratingPrompts) + ->and($session->getImages())->toHaveCount(PhotoBuilderService::IMAGE_COUNT); + + // Verify positions are 0 through IMAGE_COUNT-1 + $positions = []; + foreach ($session->getImages() as $image) { + $positions[] = $image->getPosition(); + expect($image->getStatus())->toBe(PhotoImageStatus::Pending); + } + + expect($positions)->toBe(range(0, PhotoBuilderService::IMAGE_COUNT - 1)); + }); + }); + + describe('updateImagePrompts', function (): void { + it('updates prompts for all images when no keep list provided', function (): void { + $em = $this->createMock(EntityManagerInterface::class); + $service = new PhotoBuilderService($em); + + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + + for ($i = 0; $i < 3; $i++) { + new PhotoImage($session, $i); + } + + $promptResults = [ + ['prompt' => 'Prompt A', 'fileName' => 'a.jpg'], + ['prompt' => 'Prompt B', 'fileName' => 'b.jpg'], + ['prompt' => 'Prompt C', 'fileName' => 'c.jpg'], + ]; + + $changed = $service->updateImagePrompts($session, $promptResults); + + expect($changed)->toHaveCount(3); + + $images = $session->getImages()->toArray(); + usort($images, static fn (PhotoImage $a, PhotoImage $b) => $a->getPosition() <=> $b->getPosition()); + + expect($images[0]->getPrompt())->toBe('Prompt A') + ->and($images[0]->getSuggestedFileName())->toBe('a.jpg') + ->and($images[1]->getPrompt())->toBe('Prompt B') + ->and($images[1]->getSuggestedFileName())->toBe('b.jpg') + ->and($images[2]->getPrompt())->toBe('Prompt C') + ->and($images[2]->getSuggestedFileName())->toBe('c.jpg'); + }); + + it('skips images in the keep list', function (): void { + $em = $this->createMock(EntityManagerInterface::class); + $service = new PhotoBuilderService($em); + + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + + $images = []; + for ($i = 0; $i < 3; $i++) { + $images[] = new PhotoImage($session, $i); + } + + // Pre-set image 1 with existing prompt + $images[1]->setPrompt('Original prompt'); + $images[1]->setSuggestedFileName('original.jpg'); + + // Use reflection to set IDs for the keep list + $ref = new ReflectionClass(PhotoImage::class); + $idProp = $ref->getProperty('id'); + $idProp->setValue($images[0], 'id-0'); + $idProp->setValue($images[1], 'id-1'); + $idProp->setValue($images[2], 'id-2'); + + $promptResults = [ + ['prompt' => 'New A', 'fileName' => 'new-a.jpg'], + ['prompt' => 'New B', 'fileName' => 'new-b.jpg'], + ['prompt' => 'New C', 'fileName' => 'new-c.jpg'], + ]; + + $changed = $service->updateImagePrompts($session, $promptResults, ['id-1']); + + expect($changed)->toHaveCount(2); + + // Image 1 should keep its original prompt + expect($images[1]->getPrompt())->toBe('Original prompt') + ->and($images[1]->getSuggestedFileName())->toBe('original.jpg'); + + // Images 0 and 2 should be updated + expect($images[0]->getPrompt())->toBe('New A') + ->and($images[2]->getPrompt())->toBe('New C'); + }); + + it('resets image state when updating prompts', function (): void { + $em = $this->createMock(EntityManagerInterface::class); + $service = new PhotoBuilderService($em); + + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + // Simulate a previously completed image + $image->setStatus(PhotoImageStatus::Completed); + $image->setStoragePath('old/path.png'); + $image->setErrorMessage('old error'); + + $promptResults = [ + ['prompt' => 'New prompt', 'fileName' => 'new.jpg'], + ]; + + $service->updateImagePrompts($session, $promptResults); + + expect($image->getStatus())->toBe(PhotoImageStatus::Pending) + ->and($image->getStoragePath())->toBeNull() + ->and($image->getErrorMessage())->toBeNull() + ->and($image->getPrompt())->toBe('New prompt'); + }); + }); + + describe('updateSessionStatusFromImages', function (): void { + it('does nothing when not all images are terminal', function (): void { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects($this->never())->method('flush'); + + $service = new PhotoBuilderService($em); + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + // image1 remains Pending + + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $service->updateSessionStatusFromImages($session); + + expect($session->getStatus())->toBe(PhotoSessionStatus::GeneratingImages); + }); + + it('sets ImagesReady when all images completed', function (): void { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects($this->once())->method('flush'); + + $service = new PhotoBuilderService($em); + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + $image1->setStatus(PhotoImageStatus::Completed); + + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $service->updateSessionStatusFromImages($session); + + expect($session->getStatus())->toBe(PhotoSessionStatus::ImagesReady); + }); + + it('sets ImagesReady even when some images failed', function (): void { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects($this->once())->method('flush'); + + $service = new PhotoBuilderService($em); + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + $image1->setStatus(PhotoImageStatus::Failed); + + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $service->updateSessionStatusFromImages($session); + + expect($session->getStatus())->toBe(PhotoSessionStatus::ImagesReady); + }); + }); +}); diff --git a/tests/Unit/PhotoBuilder/PhotoImageTest.php b/tests/Unit/PhotoBuilder/PhotoImageTest.php new file mode 100644 index 0000000..3f77a78 --- /dev/null +++ b/tests/Unit/PhotoBuilder/PhotoImageTest.php @@ -0,0 +1,133 @@ +getSession())->toBe($session) + ->and($image->getPosition())->toBe(2) + ->and($image->getStatus())->toBe(PhotoImageStatus::Pending) + ->and($image->getPrompt())->toBeNull() + ->and($image->getSuggestedFileName())->toBeNull() + ->and($image->getStoragePath())->toBeNull() + ->and($image->getErrorMessage())->toBeNull() + ->and($image->getCreatedAt())->toBeInstanceOf(DateTimeImmutable::class); + }); + + it('automatically adds itself to the session', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + expect($session->getImages())->toHaveCount(1) + ->and($session->getImages()->first())->toBe($image); + }); + + it('has null id before persistence', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + expect($image->getId())->toBeNull(); + }); + }); + + describe('prompt management', function (): void { + it('can set and get prompt', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + $image->setPrompt('A professional office scene'); + expect($image->getPrompt())->toBe('A professional office scene'); + }); + + it('can clear prompt by setting null', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + $image->setPrompt('Some prompt'); + $image->setPrompt(null); + expect($image->getPrompt())->toBeNull(); + }); + }); + + describe('suggested file name', function (): void { + it('can set and get suggested file name', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + $image->setSuggestedFileName('cozy-cafe-winter-scene.jpg'); + expect($image->getSuggestedFileName())->toBe('cozy-cafe-winter-scene.jpg'); + }); + }); + + describe('status transitions', function (): void { + it('can transition through all states', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + expect($image->getStatus())->toBe(PhotoImageStatus::Pending); + + $image->setStatus(PhotoImageStatus::Generating); + expect($image->getStatus())->toBe(PhotoImageStatus::Generating); + + $image->setStatus(PhotoImageStatus::Completed); + expect($image->getStatus())->toBe(PhotoImageStatus::Completed); + }); + + it('can transition to failed', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + $image->setStatus(PhotoImageStatus::Failed); + $image->setErrorMessage('API error'); + + expect($image->getStatus())->toBe(PhotoImageStatus::Failed) + ->and($image->getErrorMessage())->toBe('API error'); + }); + }); + + describe('isTerminal', function (): void { + it('returns false for pending', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + expect($image->isTerminal())->toBeFalse(); + }); + + it('returns false for generating', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + $image->setStatus(PhotoImageStatus::Generating); + expect($image->isTerminal())->toBeFalse(); + }); + + it('returns true for completed', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + $image->setStatus(PhotoImageStatus::Completed); + expect($image->isTerminal())->toBeTrue(); + }); + + it('returns true for failed', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + $image->setStatus(PhotoImageStatus::Failed); + expect($image->isTerminal())->toBeTrue(); + }); + }); + + describe('storage path', function (): void { + it('can set and get storage path', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + $image->setStoragePath('abc-123/0.png'); + expect($image->getStoragePath())->toBe('abc-123/0.png'); + }); + }); +}); diff --git a/tests/Unit/PhotoBuilder/PhotoSessionTest.php b/tests/Unit/PhotoBuilder/PhotoSessionTest.php new file mode 100644 index 0000000..778f502 --- /dev/null +++ b/tests/Unit/PhotoBuilder/PhotoSessionTest.php @@ -0,0 +1,141 @@ +getWorkspaceId())->toBe('ws-123') + ->and($session->getConversationId())->toBe('conv-456') + ->and($session->getPagePath())->toBe('index.html') + ->and($session->getUserPrompt())->toBe('Generate professional images') + ->and($session->getStatus())->toBe(PhotoSessionStatus::GeneratingPrompts) + ->and($session->getImages())->toBeEmpty() + ->and($session->getCreatedAt())->toBeInstanceOf(DateTimeImmutable::class); + }); + + it('has null id before persistence', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + expect($session->getId())->toBeNull(); + }); + }); + + describe('user prompt', function (): void { + it('can update user prompt', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'initial'); + $session->setUserPrompt('updated prompt'); + expect($session->getUserPrompt())->toBe('updated prompt'); + }); + }); + + describe('status', function (): void { + it('can transition status', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + + $session->setStatus(PhotoSessionStatus::PromptsReady); + expect($session->getStatus())->toBe(PhotoSessionStatus::PromptsReady); + + $session->setStatus(PhotoSessionStatus::GeneratingImages); + expect($session->getStatus())->toBe(PhotoSessionStatus::GeneratingImages); + + $session->setStatus(PhotoSessionStatus::ImagesReady); + expect($session->getStatus())->toBe(PhotoSessionStatus::ImagesReady); + }); + }); + + describe('images collection', function (): void { + it('adds images via addImage', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + expect($session->getImages())->toHaveCount(1) + ->and($session->getImages()->first())->toBe($image); + }); + + it('does not add duplicate images', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + // Manually try to add again + $session->addImage($image); + + expect($session->getImages())->toHaveCount(1); + }); + }); + + describe('areAllImagesTerminal', function (): void { + it('returns false when no images exist', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + expect($session->areAllImagesTerminal())->toBeFalse(); + }); + + it('returns false when some images are pending', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + // image1 remains Pending + + expect($session->areAllImagesTerminal())->toBeFalse(); + }); + + it('returns true when all images are completed', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + $image1->setStatus(PhotoImageStatus::Completed); + + expect($session->areAllImagesTerminal())->toBeTrue(); + }); + + it('returns true when all images are in terminal state including failed', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + $image1->setStatus(PhotoImageStatus::Failed); + + expect($session->areAllImagesTerminal())->toBeTrue(); + }); + }); + + describe('areAllImagesCompleted', function (): void { + it('returns false when no images exist', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + expect($session->areAllImagesCompleted())->toBeFalse(); + }); + + it('returns false when some images failed', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + $image1->setStatus(PhotoImageStatus::Failed); + + expect($session->areAllImagesCompleted())->toBeFalse(); + }); + + it('returns true when all images completed', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + $image1->setStatus(PhotoImageStatus::Completed); + + expect($session->areAllImagesCompleted())->toBeTrue(); + }); + }); +}); From 0de860ce62ce324d3519be63b524f8d034ef0f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Tue, 10 Feb 2026 12:19:27 +0100 Subject: [PATCH 02/27] Add PhotoBuilder presentation layer: controller, template, Stimulus, translations - PhotoBuilderController with all API endpoints (create session, poll, regenerate, serve image, upload to media store) - Twig template with loading state, user prompt, responsive image grid, media store sidebar - Two Stimulus controllers: photo_builder_controller.ts (orchestrator) and photo_image_controller.ts (per-card state management) - EN+DE translations for all PhotoBuilder UI strings - ImagePromptResultDto to replace associative arrays at boundaries - Registered new controllers in bootstrap.ts and asset_mapper.yaml - Service wiring in services.yaml, Twig namespace in twig.yaml - All quality checks pass (PHPStan, ESLint, tsc, Prettier, PHP CS Fixer) Co-authored-by: Cursor --- assets/bootstrap.ts | 4 + config/packages/asset_mapper.yaml | 2 + config/packages/twig.yaml | 1 + config/services.yaml | 11 + .../Domain/Dto/ImagePromptResultDto.php | 17 + .../Domain/Service/PhotoBuilderService.php | 17 +- .../Adapter/ImagePromptAgent.php | 18 +- .../Adapter/OpenAiImageGenerator.php | 6 +- .../Adapter/OpenAiPromptGenerator.php | 5 +- .../Adapter/PromptGeneratorInterface.php | 6 +- .../Handler/GenerateImageHandler.php | 9 +- .../Handler/GenerateImagePromptsHandler.php | 6 +- .../Message/GenerateImageMessage.php | 4 +- .../Message/GenerateImagePromptsMessage.php | 4 +- .../Controller/PhotoBuilderController.php | 449 ++++++++++++++++++ .../controllers/photo_builder_controller.ts | 362 ++++++++++++++ .../controllers/photo_image_controller.ts | 181 +++++++ .../Resources/templates/photo_builder.twig | 202 ++++++++ .../GeneratedImageStorageTest.php | 152 ++++-- .../PhotoBuilder/OpenAiImageGeneratorTest.php | 140 +++--- .../PhotoBuilder/PhotoBuilderServiceTest.php | 362 +++++++------- translations/messages.de.yaml | 17 + translations/messages.en.yaml | 17 + 23 files changed, 1674 insertions(+), 318 deletions(-) create mode 100644 src/PhotoBuilder/Domain/Dto/ImagePromptResultDto.php create mode 100644 src/PhotoBuilder/Presentation/Controller/PhotoBuilderController.php create mode 100644 src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_builder_controller.ts create mode 100644 src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_image_controller.ts create mode 100644 src/PhotoBuilder/Presentation/Resources/templates/photo_builder.twig diff --git a/assets/bootstrap.ts b/assets/bootstrap.ts index dc07b9c..96d50e1 100644 --- a/assets/bootstrap.ts +++ b/assets/bootstrap.ts @@ -13,6 +13,8 @@ import ManifestUrlsController from "../src/ProjectMgmt/Presentation/Resources/as import S3CredentialsController from "../src/ProjectMgmt/Presentation/Resources/assets/controllers/s3_credentials_controller.ts"; import RemoteAssetBrowserController from "../src/RemoteContentAssets/Presentation/Resources/assets/controllers/remote_asset_browser_controller.ts"; import HtmlEditorController from "../src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/html_editor_controller.ts"; +import PhotoBuilderController from "../src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_builder_controller.ts"; +import PhotoImageController from "../src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_image_controller.ts"; const app = startStimulusApp(); @@ -27,5 +29,7 @@ app.register("manifest-urls", ManifestUrlsController); app.register("s3-credentials", S3CredentialsController); app.register("remote-asset-browser", RemoteAssetBrowserController); app.register("html-editor", HtmlEditorController); +app.register("photo-builder", PhotoBuilderController); +app.register("photo-image", PhotoImageController); webuiBootstrap(app); diff --git a/config/packages/asset_mapper.yaml b/config/packages/asset_mapper.yaml index 224102e..aef21f2 100644 --- a/config/packages/asset_mapper.yaml +++ b/config/packages/asset_mapper.yaml @@ -6,6 +6,7 @@ framework: - src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/ - src/ProjectMgmt/Presentation/Resources/assets/controllers/ - src/RemoteContentAssets/Presentation/Resources/assets/controllers/ + - src/PhotoBuilder/Presentation/Resources/assets/controllers/ missing_import_mode: strict sensiolabs_typescript: @@ -14,6 +15,7 @@ sensiolabs_typescript: - "%kernel.project_dir%/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/" - "%kernel.project_dir%/src/ProjectMgmt/Presentation/Resources/assets/controllers/" - "%kernel.project_dir%/src/RemoteContentAssets/Presentation/Resources/assets/controllers/" + - "%kernel.project_dir%/src/PhotoBuilder/Presentation/Resources/assets/controllers/" when@prod: framework: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index e9117a4..a9feddb 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -9,6 +9,7 @@ twig: "src/ProjectMgmt/Presentation/Resources/templates": "project_mgmt.presentation" "src/WorkspaceMgmt/Presentation/Resources/templates": "workspace_mgmt.presentation" "src/Organization/Presentation/Resources/templates": "organization.presentation" + "src/PhotoBuilder/Presentation/Resources/templates": "photo_builder.presentation" when@test: twig: diff --git a/config/services.yaml b/config/services.yaml index a00a5bf..0ea7971 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -168,6 +168,17 @@ services: # Conversation log formatter - human-readable plain-text output App\LlmContentEditor\Infrastructure\ConversationLog\ConversationLogFormatter: ~ + # PhotoBuilder infrastructure bindings + App\PhotoBuilder\Infrastructure\Storage\GeneratedImageStorage: + arguments: + - "%kernel.project_dir%/var/photo-builder" + + App\PhotoBuilder\Infrastructure\Adapter\PromptGeneratorInterface: + class: App\PhotoBuilder\Infrastructure\Adapter\OpenAiPromptGenerator + + App\PhotoBuilder\Infrastructure\Adapter\ImageGeneratorInterface: + class: App\PhotoBuilder\Infrastructure\Adapter\OpenAiImageGenerator + # LLM content editor facade - inject loggers + enable flag App\LlmContentEditor\Facade\LlmContentEditorFacade: arguments: diff --git a/src/PhotoBuilder/Domain/Dto/ImagePromptResultDto.php b/src/PhotoBuilder/Domain/Dto/ImagePromptResultDto.php new file mode 100644 index 0000000..88548a8 --- /dev/null +++ b/src/PhotoBuilder/Domain/Dto/ImagePromptResultDto.php @@ -0,0 +1,17 @@ + $promptResults - * @param list $keepImageIds Image IDs whose prompts should not be updated + * @param list $promptResults + * @param list $keepImageIds Image IDs whose prompts should not be updated * * @return list Images whose prompts were actually changed */ public function updateImagePrompts( PhotoSession $session, - array $promptResults, - array $keepImageIds = [], + array $promptResults, + array $keepImageIds = [], ): array { $changedImages = []; $images = $session->getImages()->toArray(); @@ -72,12 +73,12 @@ public function updateImagePrompts( continue; } - if (!isset($promptResults[$index])) { + if (!array_key_exists($index, $promptResults)) { continue; } - $image->setPrompt($promptResults[$index]['prompt']); - $image->setSuggestedFileName($promptResults[$index]['fileName']); + $image->setPrompt($promptResults[$index]->prompt); + $image->setSuggestedFileName($promptResults[$index]->fileName); $image->setStatus(PhotoImageStatus::Pending); $image->setStoragePath(null); $image->setErrorMessage(null); diff --git a/src/PhotoBuilder/Infrastructure/Adapter/ImagePromptAgent.php b/src/PhotoBuilder/Infrastructure/Adapter/ImagePromptAgent.php index e0fa660..79eb5d5 100644 --- a/src/PhotoBuilder/Infrastructure/Adapter/ImagePromptAgent.php +++ b/src/PhotoBuilder/Infrastructure/Adapter/ImagePromptAgent.php @@ -4,6 +4,7 @@ namespace App\PhotoBuilder\Infrastructure\Adapter; +use App\PhotoBuilder\Domain\Dto\ImagePromptResultDto; use GuzzleHttp\HandlerStack; use NeuronAI\Agent; use NeuronAI\Providers\AIProviderInterface; @@ -24,14 +25,14 @@ */ class ImagePromptAgent extends Agent { - /** @var list */ + /** @var list */ private array $collectedPrompts = []; public function __construct( - private readonly string $apiKey, - private readonly string $pageHtml, - private readonly int $imageCount, - private readonly string $model = 'gpt-5.2', + private readonly string $apiKey, + private readonly string $pageHtml, + private readonly int $imageCount, + private readonly string $model = 'gpt-5.2', private readonly ?HandlerStack $guzzleHandlerStack = null, ) { } @@ -107,10 +108,7 @@ protected function tools(): array ) ) ->setCallable(function (string $prompt, string $file_name): string { - $this->collectedPrompts[] = [ - 'prompt' => $prompt, - 'fileName' => $file_name, - ]; + $this->collectedPrompts[] = new ImagePromptResultDto($prompt, $file_name); return 'Prompt delivered successfully.'; }), @@ -120,7 +118,7 @@ protected function tools(): array /** * Get all prompts collected via tool calls. * - * @return list + * @return list */ public function getCollectedPrompts(): array { diff --git a/src/PhotoBuilder/Infrastructure/Adapter/OpenAiImageGenerator.php b/src/PhotoBuilder/Infrastructure/Adapter/OpenAiImageGenerator.php index adaf8de..979bbdf 100644 --- a/src/PhotoBuilder/Infrastructure/Adapter/OpenAiImageGenerator.php +++ b/src/PhotoBuilder/Infrastructure/Adapter/OpenAiImageGenerator.php @@ -18,8 +18,8 @@ */ class OpenAiImageGenerator implements ImageGeneratorInterface { - private const string API_URL = 'https://api.openai.com/v1/images/generations'; - private const string MODEL = 'gpt-image-1'; + private const string API_URL = 'https://api.openai.com/v1/images/generations'; + private const string MODEL = 'gpt-image-1'; private const string IMAGE_SIZE = '1024x1024'; public function __construct( @@ -56,7 +56,7 @@ public function generateImage(string $prompt, string $apiKey): string /** @var array{data: list} $result */ $result = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); - if (!isset($result['data'][0]['b64_json'])) { + if (!array_key_exists(0, $result['data'])) { throw new RuntimeException('OpenAI image generation API returned unexpected response structure.'); } diff --git a/src/PhotoBuilder/Infrastructure/Adapter/OpenAiPromptGenerator.php b/src/PhotoBuilder/Infrastructure/Adapter/OpenAiPromptGenerator.php index f210bf8..62db06a 100644 --- a/src/PhotoBuilder/Infrastructure/Adapter/OpenAiPromptGenerator.php +++ b/src/PhotoBuilder/Infrastructure/Adapter/OpenAiPromptGenerator.php @@ -4,6 +4,7 @@ namespace App\PhotoBuilder\Infrastructure\Adapter; +use App\PhotoBuilder\Domain\Dto\ImagePromptResultDto; use GuzzleHttp\HandlerStack; use NeuronAI\Chat\Messages\UserMessage; use RuntimeException; @@ -22,13 +23,13 @@ public function __construct( } /** - * @return list + * @return list */ public function generatePrompts( string $pageHtml, string $userPrompt, string $apiKey, - int $count, + int $count, ): array { $agent = new ImagePromptAgent( $apiKey, diff --git a/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php b/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php index 8c1a5cc..548b1b4 100644 --- a/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php +++ b/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php @@ -4,6 +4,8 @@ namespace App\PhotoBuilder\Infrastructure\Adapter; +use App\PhotoBuilder\Domain\Dto\ImagePromptResultDto; + /** * Generates image prompts from a page's HTML content using an LLM. */ @@ -12,12 +14,12 @@ interface PromptGeneratorInterface /** * Generate image prompts based on page HTML content and user preferences. * - * @return list + * @return list */ public function generatePrompts( string $pageHtml, string $userPrompt, string $apiKey, - int $count, + int $count, ): array; } diff --git a/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php b/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php index 6a49225..f8f5ebe 100644 --- a/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php +++ b/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php @@ -15,6 +15,7 @@ use App\WorkspaceMgmt\Facade\WorkspaceMgmtFacadeInterface; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; +use RuntimeException; use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Throwable; @@ -68,8 +69,14 @@ public function __invoke(GenerateImageMessage $message): void ); // Store on disk + $sessionId = $session->getId(); + + if ($sessionId === null) { + throw new RuntimeException('PhotoSession has no ID.'); + } + $storagePath = $this->imageStorage->save( - $session->getId() ?? '', + $sessionId, $image->getPosition(), $imageData, ); diff --git a/src/PhotoBuilder/Infrastructure/Handler/GenerateImagePromptsHandler.php b/src/PhotoBuilder/Infrastructure/Handler/GenerateImagePromptsHandler.php index 5404ead..2b70da8 100644 --- a/src/PhotoBuilder/Infrastructure/Handler/GenerateImagePromptsHandler.php +++ b/src/PhotoBuilder/Infrastructure/Handler/GenerateImagePromptsHandler.php @@ -82,8 +82,10 @@ public function __invoke(GenerateImagePromptsMessage $message): void $this->entityManager->flush(); foreach ($session->getImages() as $image) { - if ($image->getPrompt() !== null && $image->getPrompt() !== '') { - $this->messageBus->dispatch(new GenerateImageMessage($image->getId())); + $imageId = $image->getId(); + + if ($imageId !== null && $image->getPrompt() !== null && $image->getPrompt() !== '') { + $this->messageBus->dispatch(new GenerateImageMessage($imageId)); } } } catch (Throwable $e) { diff --git a/src/PhotoBuilder/Infrastructure/Message/GenerateImageMessage.php b/src/PhotoBuilder/Infrastructure/Message/GenerateImageMessage.php index d731ab1..1ae1233 100644 --- a/src/PhotoBuilder/Infrastructure/Message/GenerateImageMessage.php +++ b/src/PhotoBuilder/Infrastructure/Message/GenerateImageMessage.php @@ -4,10 +4,12 @@ namespace App\PhotoBuilder\Infrastructure\Message; +use EnterpriseToolingForSymfony\SharedBundle\WorkerSystem\SymfonyMessage\ImmediateSymfonyMessageInterface; + /** * Dispatched to trigger async generation of a single image. */ -final readonly class GenerateImageMessage +final readonly class GenerateImageMessage implements ImmediateSymfonyMessageInterface { public function __construct( public string $imageId, diff --git a/src/PhotoBuilder/Infrastructure/Message/GenerateImagePromptsMessage.php b/src/PhotoBuilder/Infrastructure/Message/GenerateImagePromptsMessage.php index 3206ada..b1adf80 100644 --- a/src/PhotoBuilder/Infrastructure/Message/GenerateImagePromptsMessage.php +++ b/src/PhotoBuilder/Infrastructure/Message/GenerateImagePromptsMessage.php @@ -4,10 +4,12 @@ namespace App\PhotoBuilder\Infrastructure\Message; +use EnterpriseToolingForSymfony\SharedBundle\WorkerSystem\SymfonyMessage\ImmediateSymfonyMessageInterface; + /** * Dispatched to trigger async generation of image prompts for a photo session. */ -final readonly class GenerateImagePromptsMessage +final readonly class GenerateImagePromptsMessage implements ImmediateSymfonyMessageInterface { public function __construct( public string $sessionId, diff --git a/src/PhotoBuilder/Presentation/Controller/PhotoBuilderController.php b/src/PhotoBuilder/Presentation/Controller/PhotoBuilderController.php new file mode 100644 index 0000000..32a2618 --- /dev/null +++ b/src/PhotoBuilder/Presentation/Controller/PhotoBuilderController.php @@ -0,0 +1,449 @@ +accountFacade->getAccountInfoByEmail($user->getUserIdentifier()); + + if ($accountInfo === null) { + throw new RuntimeException('Account not found for authenticated user'); + } + + return $accountInfo; + } + + /** + * @return array{WorkspaceInfoDto, ProjectInfoDto} + */ + private function loadWorkspaceAndProject(string $workspaceId): array + { + $workspace = $this->workspaceMgmtFacade->getWorkspaceById($workspaceId); + + if ($workspace === null) { + throw $this->createNotFoundException('Workspace not found.'); + } + + return [$workspace, $this->projectMgmtFacade->getProjectInfo($workspace->projectId)]; + } + + /** + * Render the PhotoBuilder page. + */ + #[Route( + path: '/photo-builder/{workspaceId}', + name: 'photo_builder.presentation.show', + methods: [Request::METHOD_GET], + requirements: ['workspaceId' => '[a-f0-9-]{36}'] + )] + public function show( + string $workspaceId, + Request $request, + #[CurrentUser] UserInterface $user, + ): Response { + $this->getAccountInfo($user); + + [$workspace, $project] = $this->loadWorkspaceAndProject($workspaceId); + + $pagePath = $request->query->getString('page'); + $conversationId = $request->query->getString('conversationId'); + + if ($pagePath === '' || $conversationId === '') { + throw $this->createNotFoundException('Missing required query parameters: page, conversationId'); + } + + $hasRemoteAssets = $project->hasS3UploadConfigured() + && count($project->remoteContentAssetsManifestUrls) > 0; + + return $this->render('@photo_builder.presentation/photo_builder.twig', [ + 'workspace' => $workspace, + 'project' => $project, + 'pagePath' => $pagePath, + 'conversationId' => $conversationId, + 'imageCount' => PhotoBuilderService::IMAGE_COUNT, + 'hasRemoteAssets' => $hasRemoteAssets, + ]); + } + + /** + * Create a photo session and start prompt generation. + */ + #[Route( + path: '/api/photo-builder/sessions', + name: 'photo_builder.presentation.create_session', + methods: [Request::METHOD_POST], + )] + public function createSession( + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + if (!$this->isCsrfTokenValid('photo_builder', $request->headers->get('X-CSRF-Token', ''))) { + return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN); + } + + $this->getAccountInfo($user); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->json(['error' => 'Invalid JSON body.'], Response::HTTP_BAD_REQUEST); + } + + $workspaceId = is_string($data['workspaceId'] ?? null) ? $data['workspaceId'] : ''; + $conversationId = is_string($data['conversationId'] ?? null) ? $data['conversationId'] : ''; + $pagePath = is_string($data['pagePath'] ?? null) ? $data['pagePath'] : ''; + $userPrompt = is_string($data['userPrompt'] ?? null) ? $data['userPrompt'] : ''; + + if ($workspaceId === '' || $conversationId === '' || $pagePath === '' || $userPrompt === '') { + return $this->json(['error' => 'Missing required fields.'], Response::HTTP_BAD_REQUEST); + } + + $session = $this->photoBuilderService->createSession( + $workspaceId, + $conversationId, + $pagePath, + $userPrompt, + ); + $sessionId = $session->getId(); + + if ($sessionId === null) { + return $this->json(['error' => 'Failed to create session.'], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + $this->messageBus->dispatch(new GenerateImagePromptsMessage( + $sessionId, + $request->getLocale(), + )); + + return $this->json([ + 'sessionId' => $sessionId, + 'status' => $session->getStatus()->value, + ]); + } + + /** + * Poll session status with all image data. + */ + #[Route( + path: '/api/photo-builder/sessions/{sessionId}', + name: 'photo_builder.presentation.poll_session', + methods: [Request::METHOD_GET], + requirements: ['sessionId' => '[a-f0-9-]{36}'] + )] + public function pollSession( + string $sessionId, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + $this->getAccountInfo($user); + + $session = $this->entityManager->find(PhotoSession::class, $sessionId); + + if ($session === null) { + return $this->json(['error' => 'Session not found.'], Response::HTTP_NOT_FOUND); + } + + $images = array_map( + static fn (PhotoImage $image): array => [ + 'id' => $image->getId(), + 'position' => $image->getPosition(), + 'prompt' => $image->getPrompt(), + 'suggestedFileName' => $image->getSuggestedFileName(), + 'status' => $image->getStatus()->value, + 'imageUrl' => $image->getStoragePath() !== null + ? '/api/photo-builder/images/' . $image->getId() . '/file' + : null, + 'errorMessage' => $image->getErrorMessage(), + ], + $session->getImages()->toArray() + ); + + return $this->json([ + 'status' => $session->getStatus()->value, + 'userPrompt' => $session->getUserPrompt(), + 'images' => $images, + ]); + } + + /** + * Regenerate prompts with an updated user prompt. + */ + #[Route( + path: '/api/photo-builder/sessions/{sessionId}/regenerate-prompts', + name: 'photo_builder.presentation.regenerate_prompts', + methods: [Request::METHOD_POST], + requirements: ['sessionId' => '[a-f0-9-]{36}'] + )] + public function regeneratePrompts( + string $sessionId, + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + if (!$this->isCsrfTokenValid('photo_builder', $request->headers->get('X-CSRF-Token', ''))) { + return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN); + } + + $this->getAccountInfo($user); + + $session = $this->entityManager->find(PhotoSession::class, $sessionId); + + if ($session === null) { + return $this->json(['error' => 'Session not found.'], Response::HTTP_NOT_FOUND); + } + + $data = json_decode($request->getContent(), true); + + if (is_array($data)) { + $userPrompt = is_string($data['userPrompt'] ?? null) ? $data['userPrompt'] : ''; + + if ($userPrompt !== '') { + $session->setUserPrompt($userPrompt); + } + } + + $sessionId = $session->getId(); + + if ($sessionId === null) { + return $this->json(['error' => 'Session has no ID.'], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + $session->setStatus(PhotoSessionStatus::GeneratingPrompts); + $this->entityManager->flush(); + + $this->messageBus->dispatch(new GenerateImagePromptsMessage( + $sessionId, + $request->getLocale(), + )); + + return $this->json([ + 'status' => $session->getStatus()->value, + ]); + } + + /** + * Update prompt for a single image. + */ + #[Route( + path: '/api/photo-builder/images/{imageId}/update-prompt', + name: 'photo_builder.presentation.update_prompt', + methods: [Request::METHOD_POST], + requirements: ['imageId' => '[a-f0-9-]{36}'] + )] + public function updatePrompt( + string $imageId, + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + if (!$this->isCsrfTokenValid('photo_builder', $request->headers->get('X-CSRF-Token', ''))) { + return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN); + } + + $this->getAccountInfo($user); + + $image = $this->entityManager->find(PhotoImage::class, $imageId); + + if ($image === null) { + return $this->json(['error' => 'Image not found.'], Response::HTTP_NOT_FOUND); + } + + $data = json_decode($request->getContent(), true); + + if (is_array($data)) { + $prompt = is_string($data['prompt'] ?? null) ? $data['prompt'] : ''; + + if ($prompt !== '') { + $image->setPrompt($prompt); + $this->entityManager->flush(); + } + } + + return $this->json(['status' => 'ok']); + } + + /** + * Regenerate a single image. + */ + #[Route( + path: '/api/photo-builder/images/{imageId}/regenerate', + name: 'photo_builder.presentation.regenerate_image', + methods: [Request::METHOD_POST], + requirements: ['imageId' => '[a-f0-9-]{36}'] + )] + public function regenerateImage( + string $imageId, + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + if (!$this->isCsrfTokenValid('photo_builder', $request->headers->get('X-CSRF-Token', ''))) { + return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN); + } + + $this->getAccountInfo($user); + + $image = $this->entityManager->find(PhotoImage::class, $imageId); + + if ($image === null) { + return $this->json(['error' => 'Image not found.'], Response::HTTP_NOT_FOUND); + } + + $imageId = $image->getId(); + + if ($imageId === null) { + return $this->json(['error' => 'Image has no ID.'], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + $image->setStatus(PhotoImageStatus::Pending); + $image->setStoragePath(null); + $image->setErrorMessage(null); + + $session = $image->getSession(); + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $this->entityManager->flush(); + + $this->messageBus->dispatch(new GenerateImageMessage($imageId)); + + return $this->json(['status' => 'ok']); + } + + /** + * Serve a generated image file. + */ + #[Route( + path: '/api/photo-builder/images/{imageId}/file', + name: 'photo_builder.presentation.serve_image', + methods: [Request::METHOD_GET], + requirements: ['imageId' => '[a-f0-9-]{36}'] + )] + public function serveImage( + string $imageId, + #[CurrentUser] UserInterface $user, + ): Response { + $this->getAccountInfo($user); + + $image = $this->entityManager->find(PhotoImage::class, $imageId); + + if ($image === null || $image->getStoragePath() === null) { + throw $this->createNotFoundException('Image not found.'); + } + + $absolutePath = $this->imageStorage->getAbsolutePath($image->getStoragePath()); + + if (!$this->imageStorage->exists($image->getStoragePath())) { + throw $this->createNotFoundException('Image file not found on disk.'); + } + + return new BinaryFileResponse($absolutePath, 200, [ + 'Content-Type' => 'image/png', + ]); + } + + /** + * Upload a generated image to the media store (S3). + */ + #[Route( + path: '/api/photo-builder/images/{imageId}/upload-to-media-store', + name: 'photo_builder.presentation.upload_to_media_store', + methods: [Request::METHOD_POST], + requirements: ['imageId' => '[a-f0-9-]{36}'] + )] + public function uploadToMediaStore( + string $imageId, + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + if (!$this->isCsrfTokenValid('photo_builder', $request->headers->get('X-CSRF-Token', ''))) { + return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_FORBIDDEN); + } + + $this->getAccountInfo($user); + + $image = $this->entityManager->find(PhotoImage::class, $imageId); + + if ($image === null || $image->getStoragePath() === null) { + return $this->json(['error' => 'Image not found or not yet generated.'], Response::HTTP_NOT_FOUND); + } + + $session = $image->getSession(); + + [$workspace, $project] = $this->loadWorkspaceAndProject($session->getWorkspaceId()); + + if ( + !$project->hasS3UploadConfigured() + || $project->s3BucketName === null + || $project->s3Region === null + || $project->s3AccessKeyId === null + || $project->s3SecretAccessKey === null + ) { + return $this->json(['error' => 'S3 upload not configured for this project.'], Response::HTTP_BAD_REQUEST); + } + + $imageData = $this->imageStorage->read($image->getStoragePath()); + $fileName = $image->getSuggestedFileName() ?? 'generated-image-' . $image->getPosition() . '.png'; + + $uploadedUrl = $this->remoteContentAssetsFacade->uploadAsset( + $project->s3BucketName, + $project->s3Region, + $project->s3AccessKeyId, + $project->s3SecretAccessKey, + $project->s3IamRoleArn, + $project->s3KeyPrefix, + $fileName, + $imageData, + 'image/png', + ); + + return $this->json([ + 'url' => $uploadedUrl, + 'fileName' => $fileName, + ]); + } +} diff --git a/src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_builder_controller.ts b/src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_builder_controller.ts new file mode 100644 index 0000000..eae3e97 --- /dev/null +++ b/src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_builder_controller.ts @@ -0,0 +1,362 @@ +import { Controller } from "@hotwired/stimulus"; + +interface SessionResponse { + sessionId?: string; + status: string; + userPrompt?: string; + images?: ImageData[]; + error?: string; +} + +interface ImageData { + id: string; + position: number; + prompt: string | null; + suggestedFileName: string | null; + status: string; + imageUrl: string | null; + errorMessage: string | null; +} + +interface PromptEditedDetail { + position: number; + prompt: string; +} + +interface RegenerateRequestedDetail { + position: number; + imageId: string; + prompt: string; +} + +interface UploadRequestedDetail { + position: number; + imageId: string; + suggestedFileName: string; +} + +/** + * Orchestrator controller for the PhotoBuilder page. + * + * Manages session lifecycle, polling, global state, + * and coordinates child photo-image controllers via events. + */ +export default class extends Controller { + static values = { + createSessionUrl: String, + pollUrlPattern: String, + regeneratePromptsUrlPattern: String, + regenerateImageUrlPattern: String, + updatePromptUrlPattern: String, + uploadToMediaStoreUrlPattern: String, + csrfToken: String, + workspaceId: String, + pagePath: String, + conversationId: String, + imageCount: Number, + defaultUserPrompt: String, + editorUrl: String, + hasRemoteAssets: Boolean, + }; + + static targets = [ + "loadingOverlay", + "mainContent", + "userPrompt", + "regeneratePromptsButton", + "embedButton", + "imageCard", + ]; + + declare readonly createSessionUrlValue: string; + declare readonly pollUrlPatternValue: string; + declare readonly regeneratePromptsUrlPatternValue: string; + declare readonly regenerateImageUrlPatternValue: string; + declare readonly updatePromptUrlPatternValue: string; + declare readonly uploadToMediaStoreUrlPatternValue: string; + declare readonly csrfTokenValue: string; + declare readonly workspaceIdValue: string; + declare readonly pagePathValue: string; + declare readonly conversationIdValue: string; + declare readonly imageCountValue: number; + declare readonly defaultUserPromptValue: string; + declare readonly editorUrlValue: string; + declare readonly hasRemoteAssetsValue: boolean; + + declare readonly loadingOverlayTarget: HTMLElement; + declare readonly mainContentTarget: HTMLElement; + declare readonly userPromptTarget: HTMLTextAreaElement; + declare readonly regeneratePromptsButtonTarget: HTMLButtonElement; + declare readonly hasEmbedButtonTarget: boolean; + declare readonly embedButtonTarget: HTMLButtonElement; + declare readonly imageCardTargets: HTMLElement[]; + + private sessionId: string | null = null; + private pollingTimeoutId: ReturnType | null = null; + private isActive = false; + private anyGenerating = false; + private lastImages: ImageData[] = []; + + connect(): void { + this.isActive = true; + this.createSession(); + } + + disconnect(): void { + this.isActive = false; + this.stopPolling(); + } + + private stopPolling(): void { + if (this.pollingTimeoutId !== null) { + clearTimeout(this.pollingTimeoutId); + this.pollingTimeoutId = null; + } + } + + private scheduleNextPoll(): void { + if (this.isActive && this.sessionId) { + this.pollingTimeoutId = setTimeout(() => this.poll(), 1000); + } + } + + private async createSession(): Promise { + try { + const response = await fetch(this.createSessionUrlValue, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfTokenValue, + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ + workspaceId: this.workspaceIdValue, + conversationId: this.conversationIdValue, + pagePath: this.pagePathValue, + userPrompt: this.defaultUserPromptValue, + }), + }); + + const data = (await response.json()) as SessionResponse; + + if (data.sessionId) { + this.sessionId = data.sessionId; + this.poll(); + } + } catch { + // Session creation failed - keep loading state + } + } + + private async poll(): Promise { + if (!this.sessionId) return; + + try { + const url = this.pollUrlPatternValue.replace("___SESSION_ID___", this.sessionId); + const response = await fetch(url, { + headers: { "X-Requested-With": "XMLHttpRequest" }, + }); + + if (response.ok) { + const data = (await response.json()) as SessionResponse; + this.handlePollResponse(data); + } + } catch { + // Silently ignore polling errors + } + + this.scheduleNextPoll(); + } + + private handlePollResponse(data: SessionResponse): void { + const status = data.status; + const images = data.images || []; + this.lastImages = images; + + // Check if any image is currently generating + this.anyGenerating = + status === "generating_prompts" || + status === "generating_images" || + images.some((img) => img.status === "generating" || img.status === "pending"); + + // Show/hide loading overlay + if (status === "generating_prompts" && !images.some((img) => img.prompt)) { + this.loadingOverlayTarget.classList.remove("hidden"); + this.mainContentTarget.classList.add("hidden"); + } else { + this.loadingOverlayTarget.classList.add("hidden"); + this.mainContentTarget.classList.remove("hidden"); + } + + // Update user prompt if not focused + if (data.userPrompt && document.activeElement !== this.userPromptTarget) { + this.userPromptTarget.value = data.userPrompt; + } + + // Update button states + this.updateButtonStates(); + + // Dispatch state changes to each image card + for (const image of images) { + const card = this.imageCardTargets[image.position]; + if (card) { + card.dispatchEvent( + new CustomEvent("photo-builder:stateChanged", { + detail: image, + bubbles: false, + }), + ); + } + } + } + + private updateButtonStates(): void { + // Disable regenerate prompts button while generating + if (this.regeneratePromptsButtonTarget) { + this.regeneratePromptsButtonTarget.disabled = this.anyGenerating; + } + + // Disable embed button while generating + if (this.hasEmbedButtonTarget) { + this.embedButtonTarget.disabled = this.anyGenerating; + } + + // Toggle a data attribute on the container for child controllers + this.element.setAttribute("data-photo-builder-generating", this.anyGenerating ? "true" : "false"); + } + + /** + * Handle "Regenerate image prompts" button click. + */ + async regeneratePrompts(): Promise { + if (!this.sessionId || this.anyGenerating) return; + + // Collect kept image IDs from child controllers + const keptImageIds: string[] = []; + for (const card of this.imageCardTargets) { + const keepCheckbox = card.querySelector( + '[data-photo-image-target="keepCheckbox"]', + ) as HTMLInputElement | null; + const imageId = card.getAttribute("data-photo-image-image-id"); + if (keepCheckbox?.checked && imageId) { + keptImageIds.push(imageId); + } + } + + try { + const url = this.regeneratePromptsUrlPatternValue.replace("___SESSION_ID___", this.sessionId); + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfTokenValue, + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ + userPrompt: this.userPromptTarget.value, + keepImageIds: keptImageIds, + }), + }); + + // Polling will pick up the new state + } catch { + // Silently ignore + } + } + + /** + * Handle photo-image:promptEdited event from child. + */ + handlePromptEdited(event: CustomEvent): void { + const { imageId } = event.detail as unknown as { + imageId: string; + prompt: string; + }; + if (!imageId) return; + + // Persist prompt update to backend + const url = this.updatePromptUrlPatternValue.replace("___IMAGE_ID___", imageId); + fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfTokenValue, + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ + prompt: (event.detail as unknown as { prompt: string }).prompt, + }), + }).catch(() => { + // Silently ignore + }); + } + + /** + * Handle photo-image:regenerateRequested event from child. + */ + async handleRegenerateImage(event: CustomEvent): Promise { + const { imageId } = event.detail; + if (!imageId || this.anyGenerating) return; + + try { + const url = this.regenerateImageUrlPatternValue.replace("___IMAGE_ID___", imageId); + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfTokenValue, + "X-Requested-With": "XMLHttpRequest", + }, + }); + } catch { + // Silently ignore + } + } + + /** + * Handle photo-image:uploadRequested event from child. + */ + async handleUploadToMediaStore(event: CustomEvent): Promise { + const { imageId } = event.detail; + if (!imageId) return; + + try { + const url = this.uploadToMediaStoreUrlPatternValue.replace("___IMAGE_ID___", imageId); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfTokenValue, + "X-Requested-With": "XMLHttpRequest", + }, + }); + + if (response.ok) { + // Successfully uploaded — could trigger a refresh of the asset browser + } + } catch { + // Silently ignore + } + } + + /** + * Handle remote-asset-browser:uploadComplete event. + */ + handleMediaStoreUploadComplete(): void { + // Asset browser handles its own refresh + } + + /** + * Navigate back to editor with pre-filled embed message. + */ + embedIntoPage(): void { + const fileNames = this.lastImages + .filter((img) => img.suggestedFileName && img.status === "completed") + .map((img) => img.suggestedFileName) + .join(", "); + + const message = `Embed images ${fileNames} into page ${this.pagePathValue}`; + const url = `${this.editorUrlValue}?prefill=${encodeURIComponent(message)}`; + window.location.href = url; + } +} diff --git a/src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_image_controller.ts b/src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_image_controller.ts new file mode 100644 index 0000000..d49aca6 --- /dev/null +++ b/src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_image_controller.ts @@ -0,0 +1,181 @@ +import { Controller } from "@hotwired/stimulus"; + +interface ImageStateDetail { + id: string; + position: number; + prompt: string | null; + suggestedFileName: string | null; + status: string; + imageUrl: string | null; + errorMessage: string | null; +} + +/** + * Per-image card controller for the PhotoBuilder. + * + * Manages individual image display, prompt editing, + * and dispatches events to the parent photo-builder controller. + */ +export default class extends Controller { + static values = { + position: Number, + hasMediaStore: Boolean, + }; + + static targets = [ + "image", + "placeholder", + "promptTextarea", + "keepCheckbox", + "regenerateButton", + "uploadButton", + "statusBadge", + ]; + + declare readonly positionValue: number; + declare readonly hasMediaStoreValue: boolean; + + declare readonly imageTarget: HTMLImageElement; + declare readonly placeholderTarget: HTMLElement; + declare readonly promptTextareaTarget: HTMLTextAreaElement; + declare readonly keepCheckboxTarget: HTMLInputElement; + declare readonly regenerateButtonTarget: HTMLButtonElement; + declare readonly hasUploadButtonTarget: boolean; + declare readonly uploadButtonTarget: HTMLButtonElement; + declare readonly statusBadgeTarget: HTMLElement; + + private imageId: string | null = null; + private currentStatus = "pending"; + private suggestedFileName: string | null = null; + + /** + * Called by parent photo-builder controller via event dispatch. + */ + updateFromState(event: CustomEvent): void { + const data = event.detail; + + this.imageId = data.id; + this.currentStatus = data.status; + this.suggestedFileName = data.suggestedFileName; + + // Store imageId on the element for parent to read + this.element.setAttribute("data-photo-image-image-id", data.id); + + // Update prompt textarea (if not currently focused) + if (data.prompt !== null && document.activeElement !== this.promptTextareaTarget) { + this.promptTextareaTarget.value = data.prompt; + } + + // Update image visibility + this.updateImageDisplay(data); + + // Update status badge + this.updateStatusBadge(data); + + // Update button states based on parent's generating state + this.updateButtonStates(); + } + + private updateImageDisplay(data: ImageStateDetail): void { + if (data.status === "completed" && data.imageUrl) { + this.imageTarget.src = data.imageUrl; + this.imageTarget.classList.remove("hidden"); + this.placeholderTarget.classList.add("hidden"); + } else if (data.status === "generating" || data.status === "pending") { + this.imageTarget.classList.add("hidden"); + this.placeholderTarget.classList.remove("hidden"); + } else if (data.status === "failed") { + this.imageTarget.classList.add("hidden"); + this.placeholderTarget.classList.remove("hidden"); + // Show error in placeholder + this.placeholderTarget.innerHTML = ` +
+ + + + ${data.errorMessage || "Generation failed"} +
+ `; + } + } + + private updateStatusBadge(data: ImageStateDetail): void { + const badge = this.statusBadgeTarget; + + if (data.status === "completed") { + badge.classList.remove("hidden"); + badge.className = + "absolute top-2 right-2 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300"; + badge.textContent = "Done"; + } else if (data.status === "generating") { + badge.classList.remove("hidden"); + badge.className = + "absolute top-2 right-2 px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300 animate-pulse"; + badge.textContent = "Generating..."; + } else if (data.status === "failed") { + badge.classList.remove("hidden"); + badge.className = + "absolute top-2 right-2 px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300"; + badge.textContent = "Failed"; + } else { + badge.classList.add("hidden"); + } + } + + private updateButtonStates(): void { + const parentGenerating = + this.element.closest("[data-photo-builder-generating]")?.getAttribute("data-photo-builder-generating") === + "true"; + + this.regenerateButtonTarget.disabled = parentGenerating || this.currentStatus === "generating"; + + if (this.hasUploadButtonTarget) { + this.uploadButtonTarget.disabled = parentGenerating || this.currentStatus !== "completed"; + } + } + + /** + * Handle prompt textarea input — auto-check "Keep prompt" and dispatch event. + */ + onPromptInput(): void { + this.keepCheckboxTarget.checked = true; + + this.dispatch("promptEdited", { + detail: { + position: this.positionValue, + imageId: this.imageId, + prompt: this.promptTextareaTarget.value, + }, + }); + } + + /** + * Handle "Regenerate image" button click. + */ + requestRegenerate(): void { + if (!this.imageId) return; + + this.dispatch("regenerateRequested", { + detail: { + position: this.positionValue, + imageId: this.imageId, + prompt: this.promptTextareaTarget.value, + }, + }); + } + + /** + * Handle "Upload to media store" button click. + */ + requestUpload(): void { + if (!this.imageId) return; + + this.dispatch("uploadRequested", { + detail: { + position: this.positionValue, + imageId: this.imageId, + suggestedFileName: this.suggestedFileName ?? "", + }, + }); + } +} diff --git a/src/PhotoBuilder/Presentation/Resources/templates/photo_builder.twig b/src/PhotoBuilder/Presentation/Resources/templates/photo_builder.twig new file mode 100644 index 0000000..c826186 --- /dev/null +++ b/src/PhotoBuilder/Presentation/Resources/templates/photo_builder.twig @@ -0,0 +1,202 @@ +{% extends '@common.presentation/base_appshell.html.twig' %} + +{% block title %}{{ 'photo_builder.title'|trans }} — {{ pagePath }}{% endblock %} + +{% block body %} +
+ + {# Header #} +
+
+

+ {{ 'photo_builder.title'|trans }} +

+

+ {{ pagePath }} +

+
+ + + + + {{ 'photo_builder.back_to_editor'|trans }} + +
+ +
+ {# Main content area #} +
+ + {# Loading overlay #} +
+
+

+ {{ 'photo_builder.generating_prompts'|trans }} +

+
+ + {# Main content (hidden until prompts ready) #} + +
+ + {# Media store sidebar (conditional) #} + {% if hasRemoteAssets %} +
+

+ {{ 'remote_content_assets.browser_title'|trans }} +

+
+ {# Content loaded dynamically by remote-asset-browser controller #} +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php b/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php index b0f52c3..cccdbaf 100644 --- a/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php +++ b/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php @@ -4,91 +4,145 @@ use App\PhotoBuilder\Infrastructure\Storage\GeneratedImageStorage; -describe('GeneratedImageStorage', function (): void { - beforeEach(function (): void { - $this->baseDir = sys_get_temp_dir() . '/photo-builder-test-' . uniqid(); - $this->storage = new GeneratedImageStorage($this->baseDir); - }); +function cleanupTestDir(string $dir): void +{ + if (!is_dir($dir)) { + return; + } + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ) as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } - afterEach(function (): void { - // Clean up temp directory - if (is_dir($this->baseDir)) { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($this->baseDir, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST - ); - - foreach ($iterator as $file) { - if ($file->isDir()) { - rmdir($file->getPathname()); - } else { - unlink($file->getPathname()); - } - } + rmdir($dir); +} - rmdir($this->baseDir); - } - }); +/** + * @return array{string, GeneratedImageStorage} + */ +function createStorageFixture(): array +{ + $baseDir = sys_get_temp_dir() . '/photo-builder-test-' . uniqid(); + return [$baseDir, new GeneratedImageStorage($baseDir)]; +} + +describe('GeneratedImageStorage', function (): void { describe('save', function (): void { it('saves image data and returns relative path', function (): void { - $imageData = 'fake-png-data'; - $storagePath = $this->storage->save('session-123', 0, $imageData); + [$baseDir, $storage] = createStorageFixture(); + + try { + $imageData = 'fake-png-data'; + $storagePath = $storage->save('session-123', 0, $imageData); - expect($storagePath)->toBe('session-123/0.png'); + expect($storagePath)->toBe('session-123/0.png'); - $absolutePath = $this->baseDir . '/' . $storagePath; - expect(file_exists($absolutePath))->toBeTrue() - ->and(file_get_contents($absolutePath))->toBe('fake-png-data'); + $absolutePath = $baseDir . '/' . $storagePath; + expect(file_exists($absolutePath))->toBeTrue() + ->and(file_get_contents($absolutePath))->toBe('fake-png-data'); + } finally { + cleanupTestDir($baseDir); + } }); it('creates directory structure if not exists', function (): void { - $this->storage->save('new-session', 3, 'data'); + [$baseDir, $storage] = createStorageFixture(); + + try { + $storage->save('new-session', 3, 'data'); - $dir = $this->baseDir . '/new-session'; - expect(is_dir($dir))->toBeTrue(); + $dir = $baseDir . '/new-session'; + expect(is_dir($dir))->toBeTrue(); + } finally { + cleanupTestDir($baseDir); + } }); it('handles multiple positions in same session', function (): void { - $this->storage->save('session-abc', 0, 'image-0'); - $this->storage->save('session-abc', 1, 'image-1'); - $this->storage->save('session-abc', 4, 'image-4'); - - expect(file_get_contents($this->baseDir . '/session-abc/0.png'))->toBe('image-0') - ->and(file_get_contents($this->baseDir . '/session-abc/1.png'))->toBe('image-1') - ->and(file_get_contents($this->baseDir . '/session-abc/4.png'))->toBe('image-4'); + [$baseDir, $storage] = createStorageFixture(); + + try { + $storage->save('session-abc', 0, 'image-0'); + $storage->save('session-abc', 1, 'image-1'); + $storage->save('session-abc', 4, 'image-4'); + + expect(file_get_contents($baseDir . '/session-abc/0.png'))->toBe('image-0') + ->and(file_get_contents($baseDir . '/session-abc/1.png'))->toBe('image-1') + ->and(file_get_contents($baseDir . '/session-abc/4.png'))->toBe('image-4'); + } finally { + cleanupTestDir($baseDir); + } }); }); describe('read', function (): void { it('reads saved image data', function (): void { - $this->storage->save('session-123', 0, 'my-image-data'); + [$baseDir, $storage] = createStorageFixture(); + + try { + $storage->save('session-123', 0, 'my-image-data'); - $data = $this->storage->read('session-123/0.png'); - expect($data)->toBe('my-image-data'); + $data = $storage->read('session-123/0.png'); + expect($data)->toBe('my-image-data'); + } finally { + cleanupTestDir($baseDir); + } }); it('throws exception for non-existent file', function (): void { - expect(fn () => $this->storage->read('nonexistent/0.png')) - ->toThrow(RuntimeException::class); + [$baseDir, $storage] = createStorageFixture(); + + try { + expect(fn () => $storage->read('nonexistent/0.png')) + ->toThrow(RuntimeException::class); + } finally { + cleanupTestDir($baseDir); + } }); }); describe('getAbsolutePath', function (): void { it('returns absolute path for a relative storage path', function (): void { - $absolute = $this->storage->getAbsolutePath('session-123/0.png'); - expect($absolute)->toBe($this->baseDir . '/session-123/0.png'); + [$baseDir, $storage] = createStorageFixture(); + + try { + $absolute = $storage->getAbsolutePath('session-123/0.png'); + expect($absolute)->toBe($baseDir . '/session-123/0.png'); + } finally { + cleanupTestDir($baseDir); + } }); }); describe('exists', function (): void { it('returns true for existing file', function (): void { - $this->storage->save('session-123', 0, 'data'); - expect($this->storage->exists('session-123/0.png'))->toBeTrue(); + [$baseDir, $storage] = createStorageFixture(); + + try { + $storage->save('session-123', 0, 'data'); + expect($storage->exists('session-123/0.png'))->toBeTrue(); + } finally { + cleanupTestDir($baseDir); + } }); it('returns false for non-existing file', function (): void { - expect($this->storage->exists('nonexistent/0.png'))->toBeFalse(); + [$baseDir, $storage] = createStorageFixture(); + + try { + expect($storage->exists('nonexistent/0.png'))->toBeFalse(); + } finally { + cleanupTestDir($baseDir); + } }); }); }); diff --git a/tests/Unit/PhotoBuilder/OpenAiImageGeneratorTest.php b/tests/Unit/PhotoBuilder/OpenAiImageGeneratorTest.php index 419359e..ff5427e 100644 --- a/tests/Unit/PhotoBuilder/OpenAiImageGeneratorTest.php +++ b/tests/Unit/PhotoBuilder/OpenAiImageGeneratorTest.php @@ -2,68 +2,86 @@ declare(strict_types=1); +namespace Tests\Unit\PhotoBuilder; + use App\PhotoBuilder\Infrastructure\Adapter\OpenAiImageGenerator; +use PHPUnit\Framework\TestCase; +use RuntimeException; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; -describe('OpenAiImageGenerator', function (): void { - describe('generateImage', function (): void { - it('returns decoded image data on success', function (): void { - $fakeImageData = 'fake-png-image-bytes'; - $fakeB64 = base64_encode($fakeImageData); - $responsePayload = json_encode(['data' => [['b64_json' => $fakeB64]]]); - - $response = $this->createMock(ResponseInterface::class); - $response->method('getStatusCode')->willReturn(200); - $response->method('getContent')->willReturn($responsePayload); - - $httpClient = $this->createMock(HttpClientInterface::class); - $httpClient->expects($this->once()) - ->method('request') - ->with( - 'POST', - 'https://api.openai.com/v1/images/generations', - $this->callback(function (array $options) { - return $options['json']['model'] === 'gpt-image-1' - && $options['json']['response_format'] === 'b64_json' - && $options['json']['prompt'] === 'A beautiful sunset' - && str_contains($options['headers']['Authorization'], 'Bearer test-key'); - }) - ) - ->willReturn($response); - - $generator = new OpenAiImageGenerator($httpClient); - $result = $generator->generateImage('A beautiful sunset', 'test-key'); - - expect($result)->toBe($fakeImageData); - }); - - it('throws exception on non-200 status', function (): void { - $response = $this->createMock(ResponseInterface::class); - $response->method('getStatusCode')->willReturn(429); - $response->method('getContent')->willReturn('Rate limit exceeded'); - - $httpClient = $this->createMock(HttpClientInterface::class); - $httpClient->method('request')->willReturn($response); - - $generator = new OpenAiImageGenerator($httpClient); - - expect(fn () => $generator->generateImage('prompt', 'key')) - ->toThrow(RuntimeException::class, 'status 429'); - }); - - it('throws exception on missing data in response', function (): void { - $response = $this->createMock(ResponseInterface::class); - $response->method('getStatusCode')->willReturn(200); - $response->method('getContent')->willReturn(json_encode(['data' => []])); - - $httpClient = $this->createMock(HttpClientInterface::class); - $httpClient->method('request')->willReturn($response); - - $generator = new OpenAiImageGenerator($httpClient); - - expect(fn () => $generator->generateImage('prompt', 'key')) - ->toThrow(RuntimeException::class, 'unexpected response structure'); - }); - }); -}); +final class OpenAiImageGeneratorTest extends TestCase +{ + public function testReturnsDecodedImageDataOnSuccess(): void + { + $fakeImageData = 'fake-png-image-bytes'; + $fakeB64 = base64_encode($fakeImageData); + $responsePayload = json_encode(['data' => [['b64_json' => $fakeB64]]]); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->willReturn($responsePayload); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->expects(self::once()) + ->method('request') + ->with( + 'POST', + 'https://api.openai.com/v1/images/generations', + self::callback(static function (mixed $options): bool { + if (!is_array($options)) { + return false; + } + + /** @var array $json */ + $json = $options['json']; + /** @var array $headers */ + $headers = $options['headers']; + + return $json['model'] === 'gpt-image-1' + && $json['response_format'] === 'b64_json' + && $json['prompt'] === 'A beautiful sunset' + && is_string($headers['Authorization']) + && str_contains($headers['Authorization'], 'Bearer test-key'); + }) + ) + ->willReturn($response); + + $generator = new OpenAiImageGenerator($httpClient); + $result = $generator->generateImage('A beautiful sunset', 'test-key'); + + self::assertSame($fakeImageData, $result); + } + + public function testThrowsExceptionOnNon200Status(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(429); + $response->method('getContent')->willReturn('Rate limit exceeded'); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $generator = new OpenAiImageGenerator($httpClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('status 429'); + $generator->generateImage('prompt', 'key'); + } + + public function testThrowsExceptionOnMissingDataInResponse(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->willReturn(json_encode(['data' => []])); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $generator = new OpenAiImageGenerator($httpClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected response structure'); + $generator->generateImage('prompt', 'key'); + } +} diff --git a/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php b/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php index 73e7636..a704bcf 100644 --- a/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php +++ b/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php @@ -2,198 +2,204 @@ declare(strict_types=1); +namespace Tests\Unit\PhotoBuilder; + +use App\PhotoBuilder\Domain\Dto\ImagePromptResultDto; use App\PhotoBuilder\Domain\Entity\PhotoImage; use App\PhotoBuilder\Domain\Entity\PhotoSession; use App\PhotoBuilder\Domain\Enum\PhotoImageStatus; use App\PhotoBuilder\Domain\Enum\PhotoSessionStatus; use App\PhotoBuilder\Domain\Service\PhotoBuilderService; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +final class PhotoBuilderServiceTest extends TestCase +{ + public function testImageCountIsPositiveInteger(): void + { + self::assertGreaterThan(0, PhotoBuilderService::IMAGE_COUNT); + } + + public function testCreateSessionCreatesSessionWithImageCountImages(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('persist'); + $em->expects(self::once())->method('flush'); + + $service = new PhotoBuilderService($em); + $session = $service->createSession('ws-123', 'conv-456', 'index.html', 'Generate images'); + + self::assertSame('ws-123', $session->getWorkspaceId()); + self::assertSame('conv-456', $session->getConversationId()); + self::assertSame('index.html', $session->getPagePath()); + self::assertSame('Generate images', $session->getUserPrompt()); + self::assertSame(PhotoSessionStatus::GeneratingPrompts, $session->getStatus()); + self::assertCount(PhotoBuilderService::IMAGE_COUNT, $session->getImages()); + + // Verify positions are 0 through IMAGE_COUNT-1 + $positions = []; + foreach ($session->getImages() as $image) { + $positions[] = $image->getPosition(); + self::assertSame(PhotoImageStatus::Pending, $image->getStatus()); + } + + self::assertSame(range(0, PhotoBuilderService::IMAGE_COUNT - 1), $positions); + } + + public function testUpdateImagePromptsUpdatesAllImagesWhenNoKeepList(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $service = new PhotoBuilderService($em); + + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + + for ($i = 0; $i < 3; ++$i) { + new PhotoImage($session, $i); + } + + $promptResults = [ + new ImagePromptResultDto('Prompt A', 'a.jpg'), + new ImagePromptResultDto('Prompt B', 'b.jpg'), + new ImagePromptResultDto('Prompt C', 'c.jpg'), + ]; + + $changed = $service->updateImagePrompts($session, $promptResults); + + self::assertCount(3, $changed); + + $images = $session->getImages()->toArray(); + usort($images, static fn (PhotoImage $a, PhotoImage $b) => $a->getPosition() <=> $b->getPosition()); + + self::assertSame('Prompt A', $images[0]->getPrompt()); + self::assertSame('a.jpg', $images[0]->getSuggestedFileName()); + self::assertSame('Prompt B', $images[1]->getPrompt()); + self::assertSame('b.jpg', $images[1]->getSuggestedFileName()); + self::assertSame('Prompt C', $images[2]->getPrompt()); + self::assertSame('c.jpg', $images[2]->getSuggestedFileName()); + } + + public function testUpdateImagePromptsSkipsImagesInKeepList(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $service = new PhotoBuilderService($em); + + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + + $images = []; + for ($i = 0; $i < 3; ++$i) { + $images[] = new PhotoImage($session, $i); + } + + // Pre-set image 1 with existing prompt + $images[1]->setPrompt('Original prompt'); + $images[1]->setSuggestedFileName('original.jpg'); + + // Use reflection to set IDs for the keep list + $ref = new ReflectionClass(PhotoImage::class); + $idProp = $ref->getProperty('id'); + $idProp->setValue($images[0], 'id-0'); + $idProp->setValue($images[1], 'id-1'); + $idProp->setValue($images[2], 'id-2'); + + $promptResults = [ + new ImagePromptResultDto('New A', 'new-a.jpg'), + new ImagePromptResultDto('New B', 'new-b.jpg'), + new ImagePromptResultDto('New C', 'new-c.jpg'), + ]; + + $changed = $service->updateImagePrompts($session, $promptResults, ['id-1']); + + self::assertCount(2, $changed); + + // Image 1 should keep its original prompt + self::assertSame('Original prompt', $images[1]->getPrompt()); + self::assertSame('original.jpg', $images[1]->getSuggestedFileName()); + + // Images 0 and 2 should be updated + self::assertSame('New A', $images[0]->getPrompt()); + self::assertSame('New C', $images[2]->getPrompt()); + } + + public function testUpdateImagePromptsResetsImageState(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $service = new PhotoBuilderService($em); + + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + // Simulate a previously completed image + $image->setStatus(PhotoImageStatus::Completed); + $image->setStoragePath('old/path.png'); + $image->setErrorMessage('old error'); + + $promptResults = [ + new ImagePromptResultDto('New prompt', 'new.jpg'), + ]; + + $service->updateImagePrompts($session, $promptResults); + + self::assertSame(PhotoImageStatus::Pending, $image->getStatus()); + self::assertNull($image->getStoragePath()); + self::assertNull($image->getErrorMessage()); + self::assertSame('New prompt', $image->getPrompt()); + } -describe('PhotoBuilderService', function (): void { - describe('IMAGE_COUNT', function (): void { - it('is defined as 5', function (): void { - expect(PhotoBuilderService::IMAGE_COUNT)->toBe(5); - }); - }); - - describe('createSession', function (): void { - it('creates a session with IMAGE_COUNT images', function (): void { - $em = $this->createMock(EntityManagerInterface::class); - $em->expects($this->once())->method('persist'); - $em->expects($this->once())->method('flush'); - - $service = new PhotoBuilderService($em); - $session = $service->createSession('ws-123', 'conv-456', 'index.html', 'Generate images'); - - expect($session->getWorkspaceId())->toBe('ws-123') - ->and($session->getConversationId())->toBe('conv-456') - ->and($session->getPagePath())->toBe('index.html') - ->and($session->getUserPrompt())->toBe('Generate images') - ->and($session->getStatus())->toBe(PhotoSessionStatus::GeneratingPrompts) - ->and($session->getImages())->toHaveCount(PhotoBuilderService::IMAGE_COUNT); - - // Verify positions are 0 through IMAGE_COUNT-1 - $positions = []; - foreach ($session->getImages() as $image) { - $positions[] = $image->getPosition(); - expect($image->getStatus())->toBe(PhotoImageStatus::Pending); - } - - expect($positions)->toBe(range(0, PhotoBuilderService::IMAGE_COUNT - 1)); - }); - }); - - describe('updateImagePrompts', function (): void { - it('updates prompts for all images when no keep list provided', function (): void { - $em = $this->createMock(EntityManagerInterface::class); - $service = new PhotoBuilderService($em); - - $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); - - for ($i = 0; $i < 3; $i++) { - new PhotoImage($session, $i); - } - - $promptResults = [ - ['prompt' => 'Prompt A', 'fileName' => 'a.jpg'], - ['prompt' => 'Prompt B', 'fileName' => 'b.jpg'], - ['prompt' => 'Prompt C', 'fileName' => 'c.jpg'], - ]; - - $changed = $service->updateImagePrompts($session, $promptResults); - - expect($changed)->toHaveCount(3); - - $images = $session->getImages()->toArray(); - usort($images, static fn (PhotoImage $a, PhotoImage $b) => $a->getPosition() <=> $b->getPosition()); - - expect($images[0]->getPrompt())->toBe('Prompt A') - ->and($images[0]->getSuggestedFileName())->toBe('a.jpg') - ->and($images[1]->getPrompt())->toBe('Prompt B') - ->and($images[1]->getSuggestedFileName())->toBe('b.jpg') - ->and($images[2]->getPrompt())->toBe('Prompt C') - ->and($images[2]->getSuggestedFileName())->toBe('c.jpg'); - }); - - it('skips images in the keep list', function (): void { - $em = $this->createMock(EntityManagerInterface::class); - $service = new PhotoBuilderService($em); - - $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); - - $images = []; - for ($i = 0; $i < 3; $i++) { - $images[] = new PhotoImage($session, $i); - } - - // Pre-set image 1 with existing prompt - $images[1]->setPrompt('Original prompt'); - $images[1]->setSuggestedFileName('original.jpg'); - - // Use reflection to set IDs for the keep list - $ref = new ReflectionClass(PhotoImage::class); - $idProp = $ref->getProperty('id'); - $idProp->setValue($images[0], 'id-0'); - $idProp->setValue($images[1], 'id-1'); - $idProp->setValue($images[2], 'id-2'); - - $promptResults = [ - ['prompt' => 'New A', 'fileName' => 'new-a.jpg'], - ['prompt' => 'New B', 'fileName' => 'new-b.jpg'], - ['prompt' => 'New C', 'fileName' => 'new-c.jpg'], - ]; - - $changed = $service->updateImagePrompts($session, $promptResults, ['id-1']); - - expect($changed)->toHaveCount(2); - - // Image 1 should keep its original prompt - expect($images[1]->getPrompt())->toBe('Original prompt') - ->and($images[1]->getSuggestedFileName())->toBe('original.jpg'); - - // Images 0 and 2 should be updated - expect($images[0]->getPrompt())->toBe('New A') - ->and($images[2]->getPrompt())->toBe('New C'); - }); - - it('resets image state when updating prompts', function (): void { - $em = $this->createMock(EntityManagerInterface::class); - $service = new PhotoBuilderService($em); - - $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); - $image = new PhotoImage($session, 0); - - // Simulate a previously completed image - $image->setStatus(PhotoImageStatus::Completed); - $image->setStoragePath('old/path.png'); - $image->setErrorMessage('old error'); - - $promptResults = [ - ['prompt' => 'New prompt', 'fileName' => 'new.jpg'], - ]; - - $service->updateImagePrompts($session, $promptResults); - - expect($image->getStatus())->toBe(PhotoImageStatus::Pending) - ->and($image->getStoragePath())->toBeNull() - ->and($image->getErrorMessage())->toBeNull() - ->and($image->getPrompt())->toBe('New prompt'); - }); - }); - - describe('updateSessionStatusFromImages', function (): void { - it('does nothing when not all images are terminal', function (): void { - $em = $this->createMock(EntityManagerInterface::class); - $em->expects($this->never())->method('flush'); - - $service = new PhotoBuilderService($em); - $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); - $image0 = new PhotoImage($session, 0); - $image1 = new PhotoImage($session, 1); - - $image0->setStatus(PhotoImageStatus::Completed); - // image1 remains Pending + public function testUpdateSessionStatusDoesNothingWhenNotAllImagesTerminal(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::never())->method('flush'); - $session->setStatus(PhotoSessionStatus::GeneratingImages); - $service->updateSessionStatusFromImages($session); + $service = new PhotoBuilderService($em); + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + // image1 remains Pending + + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $service->updateSessionStatusFromImages($session); - expect($session->getStatus())->toBe(PhotoSessionStatus::GeneratingImages); - }); + self::assertSame(PhotoSessionStatus::GeneratingImages, $session->getStatus()); + } - it('sets ImagesReady when all images completed', function (): void { - $em = $this->createMock(EntityManagerInterface::class); - $em->expects($this->once())->method('flush'); + public function testUpdateSessionStatusSetsImagesReadyWhenAllCompleted(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('flush'); + + $service = new PhotoBuilderService($em); + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); + + $image0->setStatus(PhotoImageStatus::Completed); + $image1->setStatus(PhotoImageStatus::Completed); - $service = new PhotoBuilderService($em); - $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); - $image0 = new PhotoImage($session, 0); - $image1 = new PhotoImage($session, 1); - - $image0->setStatus(PhotoImageStatus::Completed); - $image1->setStatus(PhotoImageStatus::Completed); + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $service->updateSessionStatusFromImages($session); - $session->setStatus(PhotoSessionStatus::GeneratingImages); - $service->updateSessionStatusFromImages($session); + self::assertSame(PhotoSessionStatus::ImagesReady, $session->getStatus()); + } - expect($session->getStatus())->toBe(PhotoSessionStatus::ImagesReady); - }); + public function testUpdateSessionStatusSetsImagesReadyEvenWhenSomeFailed(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::once())->method('flush'); - it('sets ImagesReady even when some images failed', function (): void { - $em = $this->createMock(EntityManagerInterface::class); - $em->expects($this->once())->method('flush'); - - $service = new PhotoBuilderService($em); - $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); - $image0 = new PhotoImage($session, 0); - $image1 = new PhotoImage($session, 1); + $service = new PhotoBuilderService($em); + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image0 = new PhotoImage($session, 0); + $image1 = new PhotoImage($session, 1); - $image0->setStatus(PhotoImageStatus::Completed); - $image1->setStatus(PhotoImageStatus::Failed); + $image0->setStatus(PhotoImageStatus::Completed); + $image1->setStatus(PhotoImageStatus::Failed); - $session->setStatus(PhotoSessionStatus::GeneratingImages); - $service->updateSessionStatusFromImages($session); + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $service->updateSessionStatusFromImages($session); - expect($session->getStatus())->toBe(PhotoSessionStatus::ImagesReady); - }); - }); -}); + self::assertSame(PhotoSessionStatus::ImagesReady, $session->getStatus()); + } +} diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 0068e40..90fa037 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -443,6 +443,23 @@ remote_content_assets: browser_upload_success: "Upload abgeschlossen. Asset-Liste wird aktualisiert..." browser_upload_error: "Upload fehlgeschlagen. Bitte erneut versuchen." +# PhotoBuilder vertical +photo_builder: + title: "PhotoBuilder" + back_to_editor: "Zurück zum Editor" + loading: "Wird geladen..." + generating_prompts: "Bildprompts werden generiert..." + generating_image: "Wird generiert..." + user_prompt_label: "Stil-Anweisungen für Bilder" + regenerate_prompts: "Bildprompts neu generieren" + keep_prompt: "Prompt beibehalten" + regenerate_image: "Neu generieren" + upload_to_media_store: "Hochladen" + embed_into_page: "Generierte Bilder in Inhaltsseite einbetten" + default_user_prompt: "Die generierten Bilder sollen Professionalität und Kompetenz vermitteln." + prompt_placeholder: "Bildgenerierungs-Prompt..." + generate_matching_images: "Passende Bilder generieren" + # Language switcher language: en: "EN" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 17447c7..35eb38c 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -443,6 +443,23 @@ remote_content_assets: browser_upload_success: "Upload complete. Refreshing asset list..." browser_upload_error: "Upload failed. Please try again." +# PhotoBuilder vertical +photo_builder: + title: "PhotoBuilder" + back_to_editor: "Back to editor" + loading: "Loading..." + generating_prompts: "Generating image prompts..." + generating_image: "Generating..." + user_prompt_label: "Image style instructions" + regenerate_prompts: "Regenerate image prompts" + keep_prompt: "Keep prompt" + regenerate_image: "Regenerate" + upload_to_media_store: "Upload" + embed_into_page: "Embed generated images into content page" + default_user_prompt: "The generated images should convey professionalism and competence." + prompt_placeholder: "Image generation prompt..." + generate_matching_images: "Generate matching images" + # Language switcher language: en: "EN" From dc8eb748e869476f86cc7bd3d84d36d79e56ae97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Kie=C3=9Fling?= Date: Tue, 10 Feb 2026 12:32:53 +0100 Subject: [PATCH 03/27] Add Content Editor integration, database migration, and frontend tests for PhotoBuilder - Wire PhotoBuilder CTA (camera icon) into dist_files_controller for each page - Add prefillMessage support to chat-based-content-editor controller for the "Embed generated images into content page" flow - Register PhotoBuilder entities in doctrine.yaml and generate migration for photo_sessions and photo_images tables - Add Vitest tests for photo_builder_controller (23 tests) and photo_image_controller (25 tests) - Add tests for PhotoBuilder CTA in dist_files_controller (5 tests) and prefillMessage in chat_based_content_editor_controller (3 tests) Co-authored-by: Cursor --- config/packages/doctrine.yaml | 6 + migrations/Version20260210112717.php | 34 + .../ChatBasedContentEditorController.php | 4 +- .../chat_based_content_editor_controller.ts | 8 + .../controllers/dist_files_controller.ts | 22 + .../templates/chat_based_content_editor.twig | 7 +- ...at_based_content_editor_controller.test.ts | 83 ++ .../dist_files_controller.test.ts | 145 ++++ .../photo_builder_controller.test.ts | 820 ++++++++++++++++++ .../photo_image_controller.test.ts | 600 +++++++++++++ 10 files changed, 1726 insertions(+), 3 deletions(-) create mode 100644 migrations/Version20260210112717.php create mode 100644 tests/frontend/unit/PhotoBuilder/photo_builder_controller.test.ts create mode 100644 tests/frontend/unit/PhotoBuilder/photo_image_controller.test.ts diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index e54f5e6..db723e1 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -51,6 +51,12 @@ doctrine: dir: "%kernel.project_dir%/src/WorkspaceMgmt/Domain/Entity" prefix: 'App\WorkspaceMgmt\Domain\Entity' + App\PhotoBuilder\Domain\Entity: + type: attribute + is_bundle: false + dir: "%kernel.project_dir%/src/PhotoBuilder/Domain/Entity" + prefix: 'App\PhotoBuilder\Domain\Entity' + controller_resolver: auto_mapping: false diff --git a/migrations/Version20260210112717.php b/migrations/Version20260210112717.php new file mode 100644 index 0000000..41641e2 --- /dev/null +++ b/migrations/Version20260210112717.php @@ -0,0 +1,34 @@ +addSql('CREATE TABLE photo_images (id CHAR(36) NOT NULL, position INT NOT NULL, prompt LONGTEXT DEFAULT NULL, suggested_file_name VARCHAR(512) DEFAULT NULL, status VARCHAR(32) NOT NULL, storage_path VARCHAR(1024) DEFAULT NULL, error_message LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, session_id CHAR(36) NOT NULL, INDEX IDX_B5A0C942613FECDF (session_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE photo_sessions (id CHAR(36) NOT NULL, workspace_id CHAR(36) NOT NULL, conversation_id CHAR(36) NOT NULL, page_path VARCHAR(512) NOT NULL, user_prompt LONGTEXT NOT NULL, status VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('ALTER TABLE photo_images ADD CONSTRAINT FK_B5A0C942613FECDF FOREIGN KEY (session_id) REFERENCES photo_sessions (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE photo_images DROP FOREIGN KEY FK_B5A0C942613FECDF'); + $this->addSql('DROP TABLE photo_images'); + $this->addSql('DROP TABLE photo_sessions'); + } +} diff --git a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php index 47a6438..ff32943 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php @@ -238,7 +238,8 @@ public function heartbeat( )] public function show( string $conversationId, - #[CurrentUser] UserInterface $user + Request $request, + #[CurrentUser] UserInterface $user, ): Response { $conversation = $this->entityManager->find(Conversation::class, $conversationId); if ($conversation === null) { @@ -357,6 +358,7 @@ public function show( ] : null, 'remoteAssetBrowserWindowSize' => RemoteContentAssetsFacadeInterface::BROWSER_WINDOW_SIZE, 'promptSuggestions' => $promptSuggestions, + 'prefillMessage' => $request->query->getString('prefill'), ]); } diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts index 4c5dcd3..8d78dc6 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts @@ -54,6 +54,7 @@ export default class extends Controller { turns: Array, readOnly: { type: Boolean, default: false }, translations: Object, + prefillMessage: { type: String, default: "" }, }; static targets = [ @@ -79,6 +80,7 @@ export default class extends Controller { declare readonly turnsValue: TurnData[]; declare readonly readOnlyValue: boolean; declare readonly translationsValue: TranslationsData; + declare readonly prefillMessageValue: string; declare readonly hasMessagesTarget: boolean; declare readonly messagesTarget: HTMLElement; @@ -135,6 +137,12 @@ export default class extends Controller { if (activeSession && activeSession.id) { this.resumeActiveSession(activeSession); } + + // Pre-fill instruction textarea if a prefill message was provided (e.g. from PhotoBuilder) + if (this.prefillMessageValue && this.hasInstructionTarget) { + this.instructionTarget.value = this.prefillMessageValue; + this.instructionTarget.focus(); + } } disconnect(): void { diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/dist_files_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/dist_files_controller.ts index b5a5c11..d52f195 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/dist_files_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/dist_files_controller.ts @@ -25,6 +25,8 @@ export default class extends Controller { pollUrl: String, pollInterval: { type: Number, default: 3000 }, readOnly: { type: Boolean, default: false }, + photoBuilderUrlPattern: { type: String, default: "" }, + photoBuilderLabel: { type: String, default: "Generate matching images" }, }; static targets = ["list", "container"]; @@ -32,6 +34,8 @@ export default class extends Controller { declare readonly pollUrlValue: string; declare readonly pollIntervalValue: number; declare readonly readOnlyValue: boolean; + declare readonly photoBuilderUrlPatternValue: string; + declare readonly photoBuilderLabelValue: string; declare readonly hasListTarget: boolean; declare readonly listTarget: HTMLElement; @@ -125,6 +129,24 @@ export default class extends Controller { this.openHtmlEditor(fullPath); }); span.appendChild(editLink); + + // Create PhotoBuilder link (camera icon) - only when URL pattern is configured + if (this.photoBuilderUrlPatternValue) { + const photoLink = document.createElement("a"); + photoLink.href = this.photoBuilderUrlPatternValue.replace( + "__PAGE_PATH__", + encodeURIComponent(file.path), + ); + photoLink.className = "etfswui-link-icon"; + photoLink.title = this.photoBuilderLabelValue; + photoLink.innerHTML = ` + + + + + `; + span.appendChild(photoLink); + } } // Create preview link (icon + filename, inline to prevent line break) diff --git a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig index cc9f6b3..fd41ba4 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig @@ -42,7 +42,8 @@ stop: 'js.editor.stop'|trans, stopping: 'js.editor.stopping'|trans, cancelled: 'js.editor.cancelled'|trans - } + }, + prefillMessage: prefillMessage|default(''), }) }} {{ stimulus_action('chat-based-content-editor', 'handleSuggestionInsert', 'prompt-suggestions:insert') }}> @@ -367,7 +368,9 @@