-
Notifications
You must be signed in to change notification settings - Fork 0
Feature: PhotoBuilder — AI image generation for content pages #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
manuelkiessling
wants to merge
18
commits into
main
Choose a base branch
from
feature/photo-builder
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
… 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 <cursoragent@cursor.com>
…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 <cursoragent@cursor.com>
…s 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 <cursoragent@cursor.com>
…ms, image serving
- Replace invalid placeholder strings (___SESSION_ID___) in Twig template
with dummy UUIDs that satisfy Symfony route parameter requirements
- Use output_format instead of response_format for gpt-image-1 API
(response_format is a dall-e-2/dall-e-3 parameter)
- Generate image URLs via Symfony router to include locale prefix,
fixing broken image display due to missing /{_locale}/ in path
- Update vertical-wiring.md with PhotoBuilder facade dependencies
- Update corresponding unit and frontend tests
Co-authored-by: Cursor <cursoragent@cursor.com>
…dback, TestHarness - Use etfswui-* styleguide classes on PhotoBuilder page (buttons, cards, forms) - Add cursor-pointer to all CTAs via styleguide button classes - Extract Remote Assets sidebar to @common.presentation/_remote_asset_browser_sidebar.html.twig - Include shared partial in chat_based_content_editor and photo_builder - Show 'Upload has been finished' banner on PhotoBuilder when upload completes (auto-hide 5s) - Add PhotoBuilder TestHarness: FakePromptGenerator, FakeImageGenerator, env toggles - PHOTO_BUILDER_SIMULATE_IMAGE_PROMPT_GENERATION and PHOTO_BUILDER_SIMULATE_IMAGE_GENERATION in .env - Fix OpenAI image API (output_format for gpt-image-1), poll image URLs, Stimulus action wiring - IMAGE_COUNT=1 for faster testing; docs/frontendbook.md and vertical-wiring.md updates Co-authored-by: Cursor <cursoragent@cursor.com>
…er query params - Show 'Upload has been finished' banner when image-card upload succeeds (not only sidebar) - Regenerate prompts: overlay + spinner, clear unprotected prompt textareas on start - Hide overlay when poll returns generating state; add regenerating_prompts translation - Language switcher: preserve query string (page, conversationId) when switching locale on photo builder Co-authored-by: Cursor <cursoragent@cursor.com>
- Add uploadedToMediaStoreAt to PhotoImage to track S3 uploads - Persist upload state in uploadToMediaStore endpoint; idempotent when already uploaded - Include uploadedToMediaStore in poll response - Change embedIntoPage to async: upload non-uploaded images first, show 'Uploading images, please wait...' overlay, then navigate on success - Add translations for uploading_images (EN/DE) - Reset uploadedToMediaStoreAt when image is regenerated Co-authored-by: Cursor <cursoragent@cursor.com>
…prompt - Add uploadedFileName to PhotoImage for hash-prefixed S3 names in embed message - Pass keepImageIds from regenerate prompts to handler; skip regenerating kept images - Only dispatch image generation for changed prompts, not kept ones - Clear uploaded state when prompt is regenerated Co-authored-by: Cursor <cursoragent@cursor.com>
Dispatch clearPromptIfNotKept event on each child card element instead of the parent — DOM events bubble upward, so dispatching on the parent never reached child controllers. Also show "Generating..." text with pulse animation immediately on non-kept prompts, disable buttons during regeneration, and document the parent-to-child event pattern in frontendbook. Co-authored-by: Cursor <cursoragent@cursor.com>
…ider configuration Introduce a two-tier LLM configuration system: content editing (OpenAI-only) and PhotoBuilder (OpenAI or Google Gemini). Projects can either reuse content editing settings for image generation or configure a dedicated provider/key. - Rename llmApiKey/llmModelProvider to contentEditing* scope across entity, DTOs, facades, controllers, templates, and tests - Add nullable photoBuilder* LLM fields with fallback to content editing - Extend LlmModelProvider enum with Google case and model selection methods - Extend LlmModelName enum with gpt-image-1, gemini-3-pro-preview, gemini-3-pro-image-preview - Implement GeminiImageGenerator adapter and ImageGeneratorFactory - Parameterize ImagePromptAgent to support both OpenAI and Gemini providers - Add Google API key verification via Gemini models endpoint - Add PhotoBuilder LLM settings UI (Option A: reuse / Option B: dedicated) with provider selection, key input, verification, and one-click reuse - Display active provider and model names on PhotoBuilder page - Add docs/llm-usage-book.md documenting all LLM concerns and configuration Co-authored-by: Cursor <cursoragent@cursor.com>
The Stimulus controller searched for the provider radio only within its own element, missing sibling radios in the same fieldset. This caused Google Gemini keys to be verified against OpenAI, always failing. Widen the lookup scope to the closest fieldset/form ancestor. Co-authored-by: Cursor <cursoragent@cursor.com>
…nly) Lo-res mode (1K, default) enables faster iteration; hi-res mode (2K) produces higher quality output. The toggle is only shown when the effective PhotoBuilder provider is Google Gemini, since OpenAI always generates 1024x1024. Switching modes re-generates all images client-side using current prompts at the new resolution without a page reload. Co-authored-by: Cursor <cursoragent@cursor.com>
Remove fixed container_name to allow scaling, add deploy.replicas: 5. Co-authored-by: Cursor <cursoragent@cursor.com>
After uploading images to S3 via the "Embed into page" action, poll
the remote asset manifests until all uploaded filenames are confirmed
available before redirecting. This prevents the content editor from
referencing images that haven't propagated to the CDN yet.
- Add findAvailableFileNames() to RemoteContentAssetsFacade (basename
matching against merged manifests) so the logic stays in the
RemoteContentAssets vertical
- Add thin POST endpoint in PhotoBuilderController that delegates to
the facade and returns { available, allAvailable }
- Frontend polls every 3s for up to 90s, showing a spinner overlay
- Includes PHP unit tests, frontend tests, and EN/DE translations
Co-authored-by: Cursor <cursoragent@cursor.com>
Use the faster and cheaper Flash model for generating image prompts in PhotoBuilder when the Google provider is selected. Pro remains the main text model for content editing. Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Closes #90.
PhotoBuilder Feature Implementation Plan
Architecture Overview
New vertical
src/PhotoBuilder/following the existing vertical slice pattern, communicating with other verticals via facades.graph LR PhotoBuilder -->|"readWorkspaceFile (dist HTML)"| WorkspaceMgmt PhotoBuilder -->|"getProjectInfo (API key, S3 config)"| ProjectMgmt PhotoBuilder -->|"uploadAsset (S3)"| RemoteContentAssets PhotoBuilder -->|"getAccountInfoByEmail"| Account ChatBasedContentEditor -.->|"CTA link in dist files"| PhotoBuilderFacade dependencies to add to vertical-wiring.md:
readWorkspaceFile(to get page HTML for prompt generation)getProjectInfo(LLM API key, S3 credentials, manifest URLs)uploadAsset(upload generated images to S3 media store)getAccountInfoByEmail(user identity for access validation)Vertical Structure
1. Domain Layer
Entities
**PhotoSession**- tracks one photo generation session per page:id(UUID, PK)workspaceId(string)conversationId(string) - to navigate back to the content editorpagePath(string) - e.g.index.htmlsystemPrompt(text) - generated system prompt with page HTML baked inuserPrompt(text) - user-editable portionstatus(enum)createdAt(datetime)**PhotoImage**- tracks each of the IMAGE_COUNT images:id(UUID, PK)session(ManyToOne -> PhotoSession)position(int, 0 to IMAGE_COUNT-1)prompt(text, nullable)suggestedFileName(string, nullable) - descriptive name likecozy-cafe-winter-scene.jpgstatus(enum:pending,generating,completed,failed)storagePath(string, nullable) - relative path invar/photo-builder/errorMessage(string, nullable)Enums
**PhotoSessionStatus**:generating_prompts,prompts_ready,generating_images,images_ready,failed**PhotoImageStatus**:pending,generating,completed,failedService
**PhotoBuilderService**orchestrates:IMAGE_COUNT = 5as a public constant — the single source of truth for how many images are generated per session. Referenced by the prompt generator (system prompt text, expected tool call count), the session creation logic, and passed to the frontend via the controller.2. Infrastructure Layer
Prompt Generation (NeuronAI Agent with Tool)
**OpenAiPromptGenerator**- Uses NeuronAIAgentwith OpenAI provider (same as existing content editor pattern, see ContentEditorAgent.php):deliver_image_prompttool once per imagedeliver_image_prompttool with two required string parameters:prompt: the image generation prompt textfile_name: a descriptive slug filename (e.g.cozy-cafe-winter-scene.jpg)Image Generation (Direct HTTP)
**OpenAiImageGenerator**- Direct HTTP client call to OpenAI Images API (not via NeuronAI, which doesn't support image gen):POST https://api.openai.com/v1/images/generationsgpt-image-1(ordall-e-3as fallback)b64_jsonto get image data directly (avoids relying on temporary URLs)GeneratedImageStorageAsync Handlers (Messenger)
Following the pattern in RunEditSessionHandler.php:
**GenerateImagePromptsHandler**:PhotoSessionfrom DBWorkspaceMgmtFacade::readWorkspaceFile()ProjectMgmtFacade::getProjectInfo()OpenAiPromptGeneratorwhich runs the agent with thedeliver_image_prompttool; collects IMAGE_COUNT prompt+fileName pairsPhotoImageentity with prompt + suggestedFileName from the tool call resultsprompts_readyGenerateImageMessageper image**GenerateImageHandler**:PhotoImagefrom DBgeneratingOpenAiImageGeneratorwith the promptstoragePathand status tocompletedfailedwith error messageimages_readyImage Storage
**GeneratedImageStorage**- simple filesystem adapter:%kernel.project_dir%/var/photo-builder/{sessionId}/{position}.pngsave(sessionId, position, imageData): string,read(storagePath): string,getAbsolutePath(storagePath): string3. Presentation Layer
Controller
**PhotoBuilderController**with these routes:/photo-builder/{workspaceId}/api/photo-builder/sessions/api/photo-builder/sessions/{sessionId}/api/photo-builder/sessions/{sessionId}/regenerate-prompts/api/photo-builder/images/{imageId}/regenerate/api/photo-builder/images/{imageId}/update-prompt/api/photo-builder/images/{imageId}/file/api/photo-builder/images/{imageId}/upload-to-media-storePage render (
GET /photo-builder/{workspaceId}):page(required),conversationId(required)photo_builder.twigwith config values for the Stimulus controllerproject.hasS3UploadConfigured()Session poll (
GET /api/photo-builder/sessions/{sessionId}):{ "status": "images_ready", "userPrompt": "...", "images": [ { "id": "uuid", "position": 0, "prompt": "A wide-angle photograph...", "suggestedFileName": "modern-office-team.jpg", "status": "completed", "imageUrl": "/api/photo-builder/images/uuid/file", "errorMessage": null } ] }Twig Template (
photo_builder.twig)Layout:
photo-builder:Stimulus Controllers (multi-controller design)
The frontend is split across two custom controllers plus the existing
remote-asset-browser, communicating via Stimulus events (same pattern asdist-files->html-editorandremote-asset-browser->chat-based-content-editor).graph TB subgraph twig [photo_builder.twig] PB["photo-builder (orchestrator)"] PI1["photo-image #0"] PI2["photo-image #1"] PI3["photo-image ..."] RAB["remote-asset-browser (reused)"] end PI1 -->|"photo-image:promptEdited"| PB PI1 -->|"photo-image:regenerateRequested"| PB PI1 -->|"photo-image:uploadRequested"| PB PI2 -->|"photo-image:*"| PB PI3 -->|"photo-image:*"| PB RAB -->|"remote-asset-browser:uploadComplete"| PB PB -->|"photo-builder:stateChanged (on child el)"| PI1 PB -->|"photo-builder:stateChanged (on child el)"| PI21.
photo_builder_controller.ts(page orchestrator)Mounted on the outermost page container. Manages session lifecycle and global state.
Values (from Twig):
createSessionUrl,csrfToken,workspaceId,pagePath,conversationIdeditorUrl(URL to content editor for "embed" CTA navigation)pollUrl(pattern with{sessionId}placeholder)regeneratePromptsUrl(pattern with{sessionId}placeholder)imageCount(fromPhotoBuilderService::IMAGE_COUNT, passed by controller)Targets:
loadingOverlay,mainContentuserPrompt(textarea)regeneratePromptsButton,embedButtonimageCard(multiple — one per image, wrapping eachphoto-imagecontroller)Lifecycle:
connect(): POST tocreateSessionUrlto create session, show loading overlay, start pollingsetTimeoutper existing convention)photo-builder:stateChangedevent on eachimageCardtarget element with that image's dataprompts_readyand no images generating yet: auto-trigger image generation for allEvent listeners (wired via
stimulus_actionin Twig):photo-image:promptEdited->handlePromptEdited(): track which images have user-edited promptsphoto-image:regenerateRequested->handleRegenerateImage(): POST to regenerate single image, update global statephoto-image:uploadRequested->handleUploadToMediaStore(): POST to upload image to S3remote-asset-browser:uploadComplete->handleMediaStoreUploadComplete(): refresh asset listKey actions:
regeneratePromptsUrlwithuserPrompt+ list of kept image IDs (collected fromphoto-imagechildren), update global stateeditorUrlwith pre-filled message query param containing suggested filenames2.
photo_image_controller.ts(per image card)Mounted on each image card element within the grid. IMAGE_COUNT instances created by Twig loop. Manages individual image presentation and user interaction.
Values (from Twig):
position(int)regenerateUrl(pattern with{imageId}placeholder)uploadUrl(pattern with{imageId}placeholder)hasMediaStore(boolean)Targets:
image(img element),placeholder(generating animation)promptTextareakeepCheckboxregenerateButton,uploadButtonstatusBadgeInternal state (tracked in controller, not Stimulus values):
imageId(set when first poll data arrives)currentStatus(pending/generating/completed/failed)keepPrompt(boolean, auto-set on prompt edit)Event listeners:
photo-builder:stateChanged(dispatched by parent on this element) ->updateFromState(): receives image data (id, prompt, suggestedFileName, status, imageUrl, errorMessage), updates all targets accordinglyDispatched events (bubble up to parent via
this.dispatch()):promptEdited-> becomesphoto-image:promptEditedwith{ position, prompt }detailregenerateRequested-> becomesphoto-image:regenerateRequestedwith{ position, imageId, prompt }detailuploadRequested-> becomesphoto-image:uploadRequestedwith{ position, imageId, suggestedFileName }detailKey behaviors:
inputevent: auto-check "Keep prompt" checkbox, dispatchpromptEditedstatus === 'completed', show animated placeholder whenstatus === 'generating'3.
remote-asset-browser(existing, reused as-is)Embedded in the sidebar, same as in the content editor. Wired to the
photo-builderorchestrator viastimulus_actionin Twig for theuploadCompleteevent.Twig Wiring Example
4. Content Editor Integration
Modify
dist_files_controller.tsAdd a new Stimulus value:
photoBuilderUrlPattern(string) - e.g./en/photo-builder/{workspaceId}?page={pagePath}&conversationId={conversationId}In
renderFiles(), add a camera/image icon CTA next to each file (between edit and preview links), only when not in readOnly mode. This CTA links to the PhotoBuilder page for that file.Modify
chat_based_content_editor.twigPass the new
photoBuilderUrlPatternvalue to thedist-filesStimulus controller, and passconversationId.Pre-filled Chat Message
When navigating back from PhotoBuilder to the Content Editor, append a query parameter like
?prefill=Embed images a.jpg, b.jpg... into page x.html. The content editor controller reads this and pre-fills the instruction textarea. This requires a small change toChatBasedContentEditorController::show()and the chat Stimulus controller.5. Cross-Cutting Concerns (from documentation books)
Access Control and Organization Scoping
Per orgbook.md, projects are scoped to organizations. The
PhotoBuilderControllermust:#[IsGranted('ROLE_USER')]on all routesChatBasedContentEditorController— load workspace, load project, verify the user's organization matches)IN_CONVERSATION. The PhotoBuilder does not change workspace status — it's a side activity within the existing conversation.Workspace Status: No Lock Needed
Per conversationbook.md, a workspace has at most one ONGOING conversation at a time. The PhotoBuilder operates within this existing conversation context (the
conversationIdis passed as a query parameter). It reads workspace files but does not modify them, so no additional workspace locking is required.Reading Page HTML: dist/ not src/
Per workspace-isolation.md, workspace file access goes through
WorkspaceMgmtFacade::readWorkspaceFile()with secure path validation. The PhotoBuilder reads from thedist/directory (the built/rendered HTML), notsrc/, because the rendered page content is what the image generation agent needs to see.Messenger Transport
Per conversationbook.md section 4.1,
RunEditSessionMessageuses the "immediate" Symfony Messenger transport. The PhotoBuilder messages (GenerateImagePromptsMessage,GenerateImageMessage) should use the same transport. This needs to be configured inconfig/packages/messenger.yaml.LLM Wire Logging
Per llm-logging-book.md, the LLM logging system captures wire-level HTTP traffic and semantic conversation events. The
OpenAiPromptGeneratorNeuronAI agent should support the same logging infrastructure:$wireLoggerparameter (same asContentEditorAgent)HandlerStackmiddleware for wire loggingLlmConversationLogObserverfor conversation-level loggingmise run conversation-logDateAndTimeService
Per archbook.md, always use
DateAndTimeServiceinstead ofnew DateTimeImmutable()for entity timestamps. ThePhotoSessionandPhotoImageentities must use this service.Multibyte-Safe Strings
Per archbook.md, use
mb_*functions for string operations where applicable (e.g., truncating prompt text for display, sanitizing filenames).CSRF Protection
Per frontendbook.md section 3.6, all POST endpoints need CSRF validation. The controller should:
csrf_token('photo_builder'))isCsrfTokenValid)No Named Arguments
Per .cursor/rules/02-code-standards.mdc, never use named arguments in PHP function/method calls.
6. Configuration and Infrastructure
Database Migration
New tables:
photo_sessions,photo_images. Generate viamise run console make:migration.Architecture Test
Add
PhotoBuilderto the feature boundaries test in FeatureBoundariesArchTest.php (automatic since it scanssrc/).Translations
Add keys to
translations/messages.en.yamlandtranslations/messages.de.yaml:photo_builder.title,photo_builder.back_to_editorphoto_builder.loading,photo_builder.generating_prompts,photo_builder.generating_imagephoto_builder.user_prompt_label,photo_builder.regenerate_promptsphoto_builder.keep_prompt,photo_builder.regenerate_image,photo_builder.upload_to_media_storephoto_builder.embed_into_page,photo_builder.default_user_prompteditor.generate_matching_images(for the CTA on dist files list)Asset Registration
Register both new Stimulus controllers (
photo-builder,photo-image) in assets/bootstrap.ts.Add the new assets directory to config/packages/asset_mapper.yaml — both under
framework.asset_mapper.pathsandsensiolabs_typescript.source_dir(per frontendbook.md section 2.1-2.2).Messenger Configuration
Add routing for new messages in
config/packages/messenger.yaml:Frontend Tests
Add Vitest tests for the new Stimulus controllers in
tests/frontend/.PHP Tests
PhotoBuilderService,GeneratedImageStorageMade with Cursor