diff --git a/.env b/.env index adefa71..5203891 100644 --- a/.env +++ b/.env @@ -67,6 +67,10 @@ LLM_CONTENT_EDITOR_OPENAI_API_KEY=your-key-here # Set to "1" to enable raw LLM provider API wire logging (request + response payloads). # Enabled by default in dev via .env.dev. Override per environment as needed. LLM_WIRE_LOG_ENABLED=0 + +# PhotoBuilder: set to 1 to use fake/simulated adapters instead of real OpenAI calls +PHOTO_BUILDER_SIMULATE_IMAGE_PROMPT_GENERATION=0 +PHOTO_BUILDER_SIMULATE_IMAGE_GENERATION=0 ###< sitebuilder/llm-wire-log ### ###> sitebuilder/docker-execution ### 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/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/config/packages/twig.yaml b/config/packages/twig.yaml index e9117a4..183de37 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -7,8 +7,10 @@ twig: "src/Account/Presentation/Resources/templates": "account.presentation" "src/ChatBasedContentEditor/Presentation/Resources/templates": "chat_based_content_editor.presentation" "src/ProjectMgmt/Presentation/Resources/templates": "project_mgmt.presentation" + "src/RemoteContentAssets/Presentation/Resources/templates": "remote_content_assets.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..9bc3453 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -168,6 +168,21 @@ 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 + + App\PhotoBuilder\Infrastructure\Adapter\OpenAiImageGenerator: ~ + App\PhotoBuilder\Infrastructure\Adapter\GeminiImageGenerator: ~ + App\PhotoBuilder\Infrastructure\Adapter\ImageGeneratorFactory: ~ + # LLM content editor facade - inject loggers + enable flag App\LlmContentEditor\Facade\LlmContentEditorFacade: arguments: diff --git a/docker-compose.yml b/docker-compose.yml index 58050a5..bd234dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,8 @@ services: build: context: . dockerfile: docker/app/Dockerfile - container_name: etfs_${ETFS_PROJECT_NAME}_messenger + deploy: + replicas: 5 volumes: - .:/var/www - mise_data:/opt/mise diff --git a/docs/frontendbook.md b/docs/frontendbook.md index 71126fd..d14be4f 100644 --- a/docs/frontendbook.md +++ b/docs/frontendbook.md @@ -91,6 +91,23 @@ Bind **actions** to DOM events (e.g. `submit`, `click`): `stimulus_action(controllerName, methodName, event)`. Omit the third argument to use the default event for the element (e.g. `submit` for forms, `click` for buttons). +#### Multiple actions on the same element + +**IMPORTANT**: Each `{{ stimulus_action(...) }}` call renders its own `data-action` HTML attribute. If you place multiple calls on the same element, only the first takes effect — HTML silently discards duplicate attributes. + +To bind **multiple actions** on an element that also has `stimulus_controller`, chain the `|stimulus_action` **filter** off the controller call: + +```twig +
+``` + +This pipes the `StimulusAttributes` object through each filter, accumulating all action descriptors into a single `data-action` attribute. + +The same principle applies to `|stimulus_target` — use the filter form when combining with other Stimulus helpers on the same element. + --- ## 3. Controller Structure (TypeScript) @@ -229,7 +246,8 @@ Handle non‑OK responses and parse JSON or streamed bodies as needed. 5. **Twig** - `{{ stimulus_controller('kebab-name', { … }) }}` for the element that owns the behavior. - `{{ stimulus_target('kebab-name', 'targetName') }}` on elements the controller needs. - - `{{ stimulus_action('kebab-name', 'methodName', 'event') }}` to wire events. + - `{{ stimulus_action('kebab-name', 'methodName', 'event') }}` to wire events. + - **Multiple actions on one element**: use `|stimulus_action` filter chaining, never multiple `{{ stimulus_action() }}` calls (see section 2.4). 6. **Build and quality** - `mise run frontend` @@ -313,7 +331,75 @@ This pattern ensures: --- -## 6. References +## 6. Parent-to-Child Communication Between Controllers + +When a parent Stimulus controller needs to notify child controllers (e.g. an orchestrator telling per-item controllers to update), **dispatch the event on each child element, not on the parent**. + +### The Pitfall + +DOM events **bubble upward** (child → parent → document), never downward. If a parent dispatches an event on its own element, child controllers listening on *their* elements will never receive it — even with `bubbles: true`: + +```ts +// BAD: event fires on the parent element and bubbles UP to document. +// Child controllers listening on their own elements never see it. +this.element.dispatchEvent( + new CustomEvent("my-controller:doSomething", { bubbles: true }), +); +``` + +The child's Twig wiring listens on the child element: + +```twig +
+``` + +This means the `data-action` is on the **child** `
`, so it only captures `my-controller:doSomething` events that fire **on or below** that `
`. + +### The Correct Pattern + +Iterate over the child target elements and dispatch directly on each one: + +```ts +// GOOD: event fires on each child element, where the action listener lives. +for (const card of this.childCardTargets) { + card.dispatchEvent( + new CustomEvent("my-controller:doSomething", { bubbles: false }), + ); +} +``` + +This matches the pattern used for per-element state updates (e.g. passing poll data to each card): + +```ts +for (const card of this.imageCardTargets) { + card.dispatchEvent( + new CustomEvent("photo-builder:stateChanged", { + detail: imageData, + bubbles: false, + }), + ); +} +``` + +### When to Use Each Direction + +| Direction | Mechanism | Example | +|---|---|---| +| **Child → Parent** | `bubbles: true` on the child element; parent listens via `\|stimulus_action` filter | Child card emits `photo-image:uploadRequested`, parent catches it | +| **Parent → Child** | Loop over child targets, dispatch on each | Parent tells all children to clear prompt text | +| **Sibling → Sibling** | Go through a shared parent, or use `window` events | Rarely needed; prefer parent orchestration | + +### Key Points + +1. **Always dispatch on the element where the listener lives** — that's the element with the `data-action` attribute +2. **Use `bubbles: false`** for parent-to-child events — there is no reason for them to propagate further +3. **Use Stimulus targets** (e.g. `imageCardTargets`) to collect the child elements to dispatch on +4. **Child-to-parent** naturally works with bubbling — the child dispatches, the event bubbles up to the parent's element + +--- + +## 7. References - **Rules**: `.cursor/rules/05-frontend.mdc` (TypeScript, Stimulus, quality). - **Architecture**: `docs/archbook.md` — Client-Side Organization, vertical layout. diff --git a/docs/llm-usage-book.md b/docs/llm-usage-book.md new file mode 100644 index 0000000..4513e8a --- /dev/null +++ b/docs/llm-usage-book.md @@ -0,0 +1,144 @@ +# LLM Usage Book + +How AI models are used in this application, which providers are supported, and how per-project configuration works. + +--- + +## 1. Concerns + +The application has two distinct AI-powered concerns, each with its own provider and API key configuration: + +| Concern | Description | Configured via | +|---|---|---| +| **Content Editing** | Chat-based content editor that rewrites and creates web page content | `contentEditingLlmModelProvider` + `contentEditingLlmModelProviderApiKey` | +| **PhotoBuilder** | Generates image prompts from page content, then generates images from those prompts | `photoBuilderLlmModelProvider` + `photoBuilderLlmModelProviderApiKey` (or falls back to content editing settings) | + +### Content Editing + +The content editing concern powers the `ChatBasedContentEditor` vertical. An LLM agent (`ContentEditorAgent`) receives the current page content plus user instructions and streams back edits. This is the primary feature of the application and its provider/key are **always required** on every project. + +### PhotoBuilder + +The PhotoBuilder concern has two sub-steps: + +1. **Image Prompt Generation** -- An LLM agent (`ImagePromptAgent`) reads the page HTML and generates descriptive prompts for each image, plus a descriptive filename. This is a text-generation task using tool calls. +2. **Image Generation** -- An image-generation model turns each prompt into a PNG image. This is a dedicated image-generation API call. + +Both sub-steps use the same provider and API key (the project's PhotoBuilder settings). + +--- + +## 2. Providers + +| Provider | Enum value | Content Editing | Image Prompt Generation | Image Generation | +|---|---|---|---|---| +| **OpenAI** | `openai` | Yes (required) | Yes | Yes | +| **Google Gemini** | `google` | No | Yes | Yes | + +OpenAI is the only provider available for content editing. For the PhotoBuilder, either OpenAI or Google Gemini can be used. + +--- + +## 3. Models + +### Content Editing + +| Provider | Model | Enum | Purpose | +|---|---|---|---| +| OpenAI | `gpt-5.2` | `LlmModelName::Gpt52` | Text generation for content editing | + +### PhotoBuilder -- Image Prompt Generation + +| Provider | Model | Enum | Purpose | +|---|---|---|---| +| OpenAI | `gpt-5.2` | `LlmModelName::Gpt52` | Text generation with tool calls to produce image prompts | +| Google | `gemini-3-pro-preview` | `LlmModelName::Gemini3ProPreview` | Text generation with tool calls to produce image prompts | + +### PhotoBuilder -- Image Generation + +| Provider | Model | Enum | Purpose | +|---|---|---|---| +| OpenAI | `gpt-image-1` | `LlmModelName::GptImage1` | Image generation from text prompt | +| Google | `gemini-3-pro-image-preview` | `LlmModelName::Gemini3ProImagePreview` | Image generation from text prompt | + +--- + +## 4. Per-Project Configuration + +Each project stores two sets of LLM settings: + +### Content Editing (mandatory) + +- `contentEditingLlmModelProvider` -- always `openai` (the only supported provider for content editing) +- `contentEditingLlmModelProviderApiKey` -- the user's OpenAI API key + +These fields are required when creating or editing a project. + +### PhotoBuilder (optional, with fallback) + +- `photoBuilderLlmModelProvider` -- `openai` or `google`, nullable +- `photoBuilderLlmModelProviderApiKey` -- the matching API key, nullable + +**Fallback rule**: When the PhotoBuilder fields are `null`, the application uses the content editing provider and API key for the PhotoBuilder. This is the default for all projects, including prefab-created projects. + +The project form offers two options: + +- **Option A** -- "Use Content Editing LLM settings for image generation" (default). No additional fields are shown. Under the hood, PhotoBuilder fields remain `null`. +- **Option B** -- "Use dedicated LLM settings for image generation". The user selects a provider (OpenAI or Google) and provides an API key. The same one-click key reuse UI is available. + +### Effective Provider Resolution + +``` +function getEffectivePhotoBuilderProvider(): + if photoBuilderLlmModelProvider is not null: + return photoBuilderLlmModelProvider + return contentEditingLlmModelProvider + +function getEffectivePhotoBuilderApiKey(): + if photoBuilderLlmModelProviderApiKey is not null: + return photoBuilderLlmModelProviderApiKey + return contentEditingLlmModelProviderApiKey +``` + +--- + +## 5. Prefab Projects + +Prefab-based projects (created automatically when a new organization is set up) always use **Option A** -- the PhotoBuilder fields are not set in the prefab YAML and default to `null`, meaning they reuse the content editing settings. + +The `keysVisible` flag in the prefab configuration controls whether API keys are shown to users in the project form. When `keysVisible` is `false`, keys are used by the application but never displayed or editable. + +--- + +## 6. Key Security + +- API keys are stored encrypted at rest in the database. +- The `keysVisible` flag prevents prefab-managed keys from being shown in the UI. +- The one-click key reuse feature only shows abbreviated keys (`sk-abc...xyz`) and filters by organization to prevent cross-organization leakage. +- API keys are never sent to the frontend; verification happens server-side via dedicated AJAX endpoints. + +--- + +## 7. Architecture + +### Prompt Generation + +The `ImagePromptAgent` (NeuronAI agent) supports both providers: + +- **OpenAI**: uses `NeuronAI\Providers\OpenAI\OpenAI` with model `gpt-5.2` +- **Google**: uses `NeuronAI\Providers\Gemini\Gemini` with model `gemini-3-pro-preview` + +The agent's `provider()` method selects the right NeuronAI provider based on the project's effective PhotoBuilder provider. + +### Image Generation + +Image generation uses dedicated adapter classes behind `ImageGeneratorInterface`: + +- `OpenAiImageGenerator` -- calls OpenAI's `/v1/images/generations` endpoint with model `gpt-image-1` +- `GeminiImageGenerator` -- calls Google's `generativelanguage.googleapis.com` endpoint with model `gemini-3-pro-image-preview` + +A `PhotoBuilderImageGeneratorFactory` selects the correct adapter based on the project's effective PhotoBuilder provider. + +### Content Editing + +Content editing is handled by the `ContentEditorAgent` (NeuronAI agent) using OpenAI exclusively. This path is not affected by the PhotoBuilder provider configuration. diff --git a/docs/vertical-wiring.md b/docs/vertical-wiring.md index 7d864d5..7c9f26c 100644 --- a/docs/vertical-wiring.md +++ b/docs/vertical-wiring.md @@ -8,6 +8,7 @@ flowchart LR direction TB CBCE["ChatBasedContentEditor"] LLM["LlmContentEditor"] + PB["PhotoBuilder"] WSM["WorkspaceMgmt"] WST["WorkspaceTooling"] ORG["Organization"] @@ -37,6 +38,11 @@ flowchart LR LLM -->|tools: build, preview, assets, rules| WSTF LLM -->|getAgentConfigTemplate| PRJF + PB -->|getAccountInfoByEmail| ACC + PB -->|getProjectInfo| PRJF + PB -->|readWorkspaceFile, getWorkspaceById| WSMF + PB -->|uploadAsset| RCAF + WSM -->|getProjectInfo| PRJF WSM -->|getLatestConversationId| CBCEF @@ -67,6 +73,7 @@ Method details are in the summary table below. |---------------------------|----------------------------|--------------| | **ChatBasedContentEditor** | Account, ProjectMgmt, WorkspaceMgmt, LlmContentEditor | Workspace lifecycle, commitAndPush, streamEditWithHistory, buildAgentContextDump, account resolution | | **LlmContentEditor** | WorkspaceTooling, ProjectMgmt | runQualityChecks, runTests, runBuild, suggestCommitMessage, getPreviewUrl, list/search remote assets, getWorkspaceRules; getAgentConfigTemplate (EditContentCommand) | +| **PhotoBuilder** | Account, ProjectMgmt, WorkspaceMgmt, RemoteContentAssets | getAccountInfoByEmail; getProjectInfo (API key, S3 config); readWorkspaceFile (page HTML), getWorkspaceById; uploadAsset (S3) | | **WorkspaceMgmt** | ProjectMgmt, ChatBasedContentEditor | getProjectInfo (setup, git, review); getLatestConversationId (reviewer UI) | | **WorkspaceTooling** | RemoteContentAssets | fetchAndMergeAssetUrls, getRemoteAssetInfo | | **Organization** | Prefab, ProjectMgmt, WorkspaceMgmt, Account | loadPrefabs, createProjectFromPrefab, dispatchSetupIfNeeded; account resolution and registration | @@ -81,3 +88,4 @@ Method details are in the summary table below. - **WorkspaceTooling** delegates remote asset listing/info to **RemoteContentAssets**. - **Organization** onboarding (AccountCoreCreatedSymfonyEventSubscriber) wires **Prefab → ProjectMgmt → WorkspaceMgmt** to create projects and dispatch setup. - **ProjectMgmt** presentation layer coordinates **ChatBasedContentEditor**, **WorkspaceMgmt**, **LlmContentEditor**, and **RemoteContentAssets** for project/workspace/conversation and validation flows. +- **PhotoBuilder** reads workspace page HTML via **WorkspaceMgmt**, fetches API keys and S3 config via **ProjectMgmt**, uploads generated images via **RemoteContentAssets**, and validates user access via **Account**. 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/migrations/Version20260211081136.php b/migrations/Version20260211081136.php new file mode 100644 index 0000000..15ae7df --- /dev/null +++ b/migrations/Version20260211081136.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE photo_images ADD uploaded_to_media_store_at DATETIME DEFAULT NULL'); + } + + 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 uploaded_to_media_store_at'); + } +} diff --git a/migrations/Version20260211082223.php b/migrations/Version20260211082223.php new file mode 100644 index 0000000..c0541ea --- /dev/null +++ b/migrations/Version20260211082223.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE photo_images ADD uploaded_file_name VARCHAR(512) DEFAULT NULL'); + } + + 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 uploaded_file_name'); + } +} diff --git a/migrations/Version20260211110000.php b/migrations/Version20260211110000.php new file mode 100644 index 0000000..6e869c9 --- /dev/null +++ b/migrations/Version20260211110000.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE projects CHANGE llm_model_provider content_editing_llm_model_provider VARCHAR(32) NOT NULL'); + $this->addSql('ALTER TABLE projects CHANGE llm_api_key content_editing_llm_model_provider_api_key VARCHAR(1024) NOT NULL'); + + // Add PhotoBuilder-specific LLM columns (nullable = uses content editing settings) + $this->addSql('ALTER TABLE projects ADD photo_builder_llm_model_provider VARCHAR(32) DEFAULT NULL'); + $this->addSql('ALTER TABLE projects ADD photo_builder_llm_model_provider_api_key VARCHAR(1024) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE projects DROP photo_builder_llm_model_provider_api_key'); + $this->addSql('ALTER TABLE projects DROP photo_builder_llm_model_provider'); + $this->addSql('ALTER TABLE projects CHANGE content_editing_llm_model_provider_api_key llm_api_key VARCHAR(1024) NOT NULL'); + $this->addSql('ALTER TABLE projects CHANGE content_editing_llm_model_provider llm_model_provider VARCHAR(32) NOT NULL'); + } +} diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index 2136f70..96b236b 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -79,7 +79,7 @@ public function __invoke(RunEditSessionMessage $message): void $project = $workspace !== null ? $this->projectMgmtFacade->getProjectInfo($workspace->projectId) : null; // Ensure we have a valid LLM API key from the project - if ($project === null || $project->llmApiKey === '') { + if ($project === null || $project->contentEditingApiKey === '') { $this->logger->error('EditSession failed: no LLM API key configured for project', [ 'sessionId' => $message->sessionId, 'workspaceId' => $conversation->getWorkspaceId(), @@ -113,7 +113,7 @@ public function __invoke(RunEditSessionMessage $message): void $session->getWorkspacePath(), $session->getInstruction(), $previousMessages, - $project->llmApiKey, + $project->contentEditingApiKey, $agentConfig, $message->locale, ); 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..49f2364 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/dist_files_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/dist_files_controller.ts @@ -25,18 +25,30 @@ 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" }, + editHtmlLabel: { type: String, default: "Edit HTML" }, + previewLabel: { type: String, default: "Preview" }, }; - static targets = ["list", "container"]; + static targets = ["list", "container", "photoBuilderSection", "photoBuilderLinks"]; declare readonly pollUrlValue: string; declare readonly pollIntervalValue: number; declare readonly readOnlyValue: boolean; + declare readonly photoBuilderUrlPatternValue: string; + declare readonly photoBuilderLabelValue: string; + declare readonly editHtmlLabelValue: string; + declare readonly previewLabelValue: string; declare readonly hasListTarget: boolean; declare readonly listTarget: HTMLElement; declare readonly hasContainerTarget: boolean; declare readonly containerTarget: HTMLElement; + declare readonly hasPhotoBuilderSectionTarget: boolean; + declare readonly photoBuilderSectionTarget: HTMLElement; + declare readonly hasPhotoBuilderLinksTarget: boolean; + declare readonly photoBuilderLinksTarget: HTMLElement; private pollingTimeoutId: ReturnType | null = null; private lastFilesJson: string = ""; @@ -100,48 +112,119 @@ export default class extends Controller { } this.containerTarget.classList.remove("hidden"); + this.renderFileList(files); + this.renderPhotoBuilderLinks(files); + } + + private renderFileList(files: DistFile[]): void { this.listTarget.innerHTML = ""; for (const file of files) { const li = document.createElement("li"); - const span = document.createElement("span"); - span.className = "flex items-center space-x-2 whitespace-nowrap"; - - // Create edit link (icon only) - only in edit mode + li.className = "flex items-center justify-between gap-3 py-2 group"; + + // Left side: document icon + filename + const nameWrapper = document.createElement("div"); + nameWrapper.className = "flex items-center gap-2 min-w-0"; + nameWrapper.innerHTML = + `` + + `` + + ``; + + const fileName = document.createElement("a"); + fileName.href = file.url; + fileName.target = "_blank"; + fileName.className = + "text-sm text-dark-700 dark:text-dark-300 truncate " + + "hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-150"; + fileName.textContent = file.path; + nameWrapper.appendChild(fileName); + li.appendChild(nameWrapper); + + // Right side: action buttons + const actions = document.createElement("div"); + actions.className = "flex items-center gap-1 flex-shrink-0"; + + // Edit button - only in edit mode if (!this.readOnlyValue) { const editLink = document.createElement("a"); editLink.href = "#"; - editLink.className = "etfswui-link-icon"; - editLink.title = "Edit HTML"; - editLink.innerHTML = ` - - - - `; + editLink.title = this.editHtmlLabelValue; + editLink.className = + "inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium " + + "text-dark-500 hover:text-dark-700 hover:bg-dark-100 " + + "dark:text-dark-400 dark:hover:text-dark-200 dark:hover:bg-dark-700/50 " + + "transition-colors duration-150"; + editLink.innerHTML = + `` + + `` + + `` + + `${this.editHtmlLabelValue}`; editLink.addEventListener("click", (e) => { e.preventDefault(); - // Extract full path from URL: /workspaces/{workspaceId}/{fullPath} -> {fullPath} const fullPath = file.url.split("/").slice(3).join("/"); this.openHtmlEditor(fullPath); }); - span.appendChild(editLink); + actions.appendChild(editLink); } - // Create preview link (icon + filename, inline to prevent line break) + // Preview button const previewLink = document.createElement("a"); previewLink.href = file.url; previewLink.target = "_blank"; + previewLink.title = this.previewLabelValue; previewLink.className = - "inline-flex items-center gap-1 text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300"; - previewLink.title = "Open preview"; - previewLink.innerHTML = `${file.path}`; - - span.appendChild(previewLink); - li.appendChild(span); + "inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium " + + "text-primary-600 hover:text-primary-700 hover:bg-primary-50 " + + "dark:text-primary-400 dark:hover:text-primary-300 dark:hover:bg-primary-900/20 " + + "transition-colors duration-150"; + previewLink.innerHTML = + `` + + `` + + `` + + `${this.previewLabelValue}`; + actions.appendChild(previewLink); + + li.appendChild(actions); this.listTarget.appendChild(li); } } + private renderPhotoBuilderLinks(files: DistFile[]): void { + if (!this.hasPhotoBuilderSectionTarget || !this.hasPhotoBuilderLinksTarget) { + return; + } + + if (!this.photoBuilderUrlPatternValue || this.readOnlyValue || files.length === 0) { + this.photoBuilderSectionTarget.classList.add("hidden"); + + return; + } + + this.photoBuilderSectionTarget.classList.remove("hidden"); + this.photoBuilderLinksTarget.innerHTML = ""; + + for (const file of files) { + const link = document.createElement("a"); + link.href = this.photoBuilderUrlPatternValue.replace("__PAGE_PATH__", encodeURIComponent(file.path)); + link.title = this.photoBuilderLabelValue; + link.className = + "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium " + + "bg-purple-50 text-purple-700 ring-1 ring-inset ring-purple-700/10 " + + "hover:bg-purple-100 hover:text-purple-800 " + + "dark:bg-purple-900/20 dark:text-purple-300 dark:ring-purple-400/30 " + + "dark:hover:bg-purple-900/40 dark:hover:text-purple-200 " + + "transition-colors duration-150"; + link.innerHTML = + `` + + `` + + `` + + `${file.path}`; + + this.photoBuilderLinksTarget.appendChild(link); + } + } + private openHtmlEditor(path: string): void { const event = new CustomEvent("html-editor:open", { bubbles: true, 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..77d0a10 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') }}> @@ -108,26 +109,23 @@
{% endif %} - {# Action buttons - always visible, centered above header #} -
+ {# Action buttons - always visible, right-aligned above header #} +
{% if canEdit %}
-
-
{% endif %} - + {{ 'common.back_to_projects'|trans }}
@@ -367,96 +365,65 @@ - {% endif %} -
- {# Remote assets sidebar - only show if project has manifest URLs configured #} - {% if hasRemoteAssets %} -
- {# On narrow screens: border-top separator. On wide screens: left border #} -
-
-

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

- + {# Preview Pages card #} +
+
+
+ + + +
+
+

{{ 'editor.preview_pages'|trans }}

+

{{ 'editor.preview_pages_description'|trans }}

+
    +
    +
    - {# Upload dropzone - only show if S3 is configured #} - {% if project.hasS3UploadConfigured() %} -
    - - - -

    {{ 'remote_content_assets.browser_upload_dropzone'|trans }}

    -

    {{ 'remote_content_assets.browser_upload_hint'|trans }}

    -
    - {# Upload progress indicator #} - - {# Upload success message #} - - {# Upload error message #} - + {% endif %} +
    + + {# Remote assets sidebar - only show if project has manifest URLs configured #} + {% if hasRemoteAssets %} + {% include '@remote_content_assets.presentation/_remote_asset_browser_sidebar.html.twig' with { + project: project, + workspaceId: workspace.id, + windowSize: remoteAssetBrowserWindowSize, + parentStimulusActions: [ + { controller: 'chat-based-content-editor', action: 'handleAssetInsert', event: 'remote-asset-browser:insert' }, + { controller: 'chat-based-content-editor', action: 'handleUploadComplete', event: 'remote-asset-browser:uploadComplete' } + ] + } %} {% endif %}
    diff --git a/src/Common/Presentation/Resources/templates/_language_switcher.html.twig b/src/Common/Presentation/Resources/templates/_language_switcher.html.twig index 10218b0..670b4f4 100644 --- a/src/Common/Presentation/Resources/templates/_language_switcher.html.twig +++ b/src/Common/Presentation/Resources/templates/_language_switcher.html.twig @@ -1,9 +1,17 @@ {# Language Switcher Component #} {# Compact toggle style matching the theme toggle button #} +{# Preserves query string (e.g. page, conversationId on photo builder) when switching locale #}
    {% for locale in ['en', 'de'] %} {% set is_current = app.request.locale == locale %} - diff --git a/src/LlmContentEditor/Domain/Enum/LlmModelName.php b/src/LlmContentEditor/Domain/Enum/LlmModelName.php index 7d28b4f..0d86964 100644 --- a/src/LlmContentEditor/Domain/Enum/LlmModelName.php +++ b/src/LlmContentEditor/Domain/Enum/LlmModelName.php @@ -6,12 +6,21 @@ enum LlmModelName: string { - case Gpt52 = 'gpt-5.2'; + // Text generation models + case Gpt52 = 'gpt-5.2'; + case Gemini3ProPreview = 'gemini-3-pro-preview'; + case Gemini3FlashPreview = 'gemini-3-flash-preview'; + + // Image generation models + case GptImage1 = 'gpt-image-1'; + case Gemini3ProImagePreview = 'gemini-3-pro-image-preview'; public function maxContextTokens(): int { return match ($this) { self::Gpt52 => 128_000, + self::Gemini3ProPreview, self::Gemini3FlashPreview => 1_048_576, + self::GptImage1, self::Gemini3ProImagePreview => 0, }; } @@ -21,7 +30,11 @@ public function maxContextTokens(): int public function inputCostPer1M(): float { return match ($this) { - self::Gpt52 => 1.75, + self::Gpt52 => 1.75, + self::Gemini3ProPreview => 1.25, + self::Gemini3FlashPreview => 0.15, + self::GptImage1 => 0.0, // image models have per-image pricing + self::Gemini3ProImagePreview => 0.0, }; } @@ -31,7 +44,11 @@ public function inputCostPer1M(): float public function outputCostPer1M(): float { return match ($this) { - self::Gpt52 => 14.00, + self::Gpt52 => 14.00, + self::Gemini3ProPreview => 10.00, + self::Gemini3FlashPreview => 0.60, + self::GptImage1 => 0.0, + self::Gemini3ProImagePreview => 0.0, }; } @@ -39,4 +56,15 @@ public static function defaultForContentEditor(): self { return self::Gpt52; } + + /** + * Returns true if this model is used for image generation (not text). + */ + public function isImageGenerationModel(): bool + { + return match ($this) { + self::GptImage1, self::Gemini3ProImagePreview => true, + default => false, + }; + } } diff --git a/src/LlmContentEditor/Facade/Enum/LlmModelProvider.php b/src/LlmContentEditor/Facade/Enum/LlmModelProvider.php index aa2cff0..4511e9d 100644 --- a/src/LlmContentEditor/Facade/Enum/LlmModelProvider.php +++ b/src/LlmContentEditor/Facade/Enum/LlmModelProvider.php @@ -13,16 +13,40 @@ enum LlmModelProvider: string { case OpenAI = 'openai'; + case Google = 'google'; /** - * Returns the models supported by this provider. + * Returns the text-generation models supported by this provider. * * @return list */ - public function supportedModels(): array + public function supportedTextModels(): array { return match ($this) { self::OpenAI => [LlmModelName::Gpt52], + self::Google => [LlmModelName::Gemini3ProPreview], + }; + } + + /** + * Returns the image-generation model for this provider. + */ + public function imageGenerationModel(): LlmModelName + { + return match ($this) { + self::OpenAI => LlmModelName::GptImage1, + self::Google => LlmModelName::Gemini3ProImagePreview, + }; + } + + /** + * Returns the text-generation model to use for image prompt generation. + */ + public function imagePromptGenerationModel(): LlmModelName + { + return match ($this) { + self::OpenAI => LlmModelName::Gpt52, + self::Google => LlmModelName::Gemini3FlashPreview, }; } @@ -31,7 +55,7 @@ public function supportedModels(): array */ public function defaultModel(): LlmModelName { - return $this->supportedModels()[0]; + return $this->supportedTextModels()[0]; } /** @@ -41,6 +65,26 @@ public function displayName(): string { return match ($this) { self::OpenAI => 'OpenAI', + self::Google => 'Google (Gemini)', }; } + + /** + * Returns true if this provider is available for content editing. + */ + public function supportsContentEditing(): bool + { + return match ($this) { + self::OpenAI => true, + self::Google => false, + }; + } + + /** + * Returns true if this provider is available for PhotoBuilder (image generation). + */ + public function supportsPhotoBuilder(): bool + { + return true; + } } diff --git a/src/LlmContentEditor/Facade/LlmContentEditorFacade.php b/src/LlmContentEditor/Facade/LlmContentEditorFacade.php index 014e443..e326f77 100644 --- a/src/LlmContentEditor/Facade/LlmContentEditorFacade.php +++ b/src/LlmContentEditor/Facade/LlmContentEditorFacade.php @@ -307,6 +307,7 @@ public function verifyApiKey(LlmModelProvider $provider, string $apiKey): bool try { return match ($provider) { LlmModelProvider::OpenAI => $this->verifyOpenAiKey($apiKey), + LlmModelProvider::Google => $this->verifyGoogleKey($apiKey), }; } catch (Throwable $e) { $this->logger->warning('API key verification failed', [ @@ -333,6 +334,23 @@ private function verifyOpenAiKey(string $apiKey): bool return $response->getStatusCode() === 200; } + /** + * Verifies a Google Gemini API key by calling the models endpoint. + */ + private function verifyGoogleKey(string $apiKey): bool + { + $response = $this->httpClient->request( + 'GET', + 'https://generativelanguage.googleapis.com/v1beta/models', + [ + 'query' => ['key' => $apiKey], + 'timeout' => 10, + ] + ); + + return $response->getStatusCode() === 200; + } + /** * Truncate a string for human-readable conversation log output. */ 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 @@ +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: true + )] + private ?DateTimeImmutable $uploadedToMediaStoreAt = null; + + public function getUploadedToMediaStoreAt(): ?DateTimeImmutable + { + return $this->uploadedToMediaStoreAt; + } + + public function setUploadedToMediaStoreAt(?DateTimeImmutable $uploadedToMediaStoreAt): void + { + $this->uploadedToMediaStoreAt = $uploadedToMediaStoreAt; + } + + #[ORM\Column( + type: Types::STRING, + length: 512, + nullable: true + )] + private ?string $uploadedFileName = null; + + /** + * The actual filename on S3 (hash-prefixed, e.g. 00fa0883ee6db2e2_image.png). + * Used when constructing the embed message so the content editor agent can find the asset. + */ + public function getUploadedFileName(): ?string + { + return $this->uploadedFileName; + } + + public function setUploadedFileName(?string $uploadedFileName): void + { + $this->uploadedFileName = $uploadedFileName; + } + + #[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 (!array_key_exists($index, $promptResults)) { + continue; + } + + $image->setPrompt($promptResults[$index]->prompt); + $image->setSuggestedFileName($promptResults[$index]->fileName); + $image->setStatus(PhotoImageStatus::Pending); + $image->setStoragePath(null); + $image->setErrorMessage(null); + $image->setUploadedToMediaStoreAt(null); + $image->setUploadedFileName(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/GeminiImageGenerator.php b/src/PhotoBuilder/Infrastructure/Adapter/GeminiImageGenerator.php new file mode 100644 index 0000000..10ff7a6 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/GeminiImageGenerator.php @@ -0,0 +1,128 @@ +httpClient->request('POST', $url, [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => [ + 'key' => $apiKey, + ], + 'json' => $this->buildRequestBody($prompt, $imageSize), + ]); + + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200) { + throw new RuntimeException(sprintf( + 'Gemini image generation API returned status %d: %s', + $statusCode, + $response->getContent(false), + )); + } + + $decoded = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + if (!is_array($decoded) || !array_key_exists('candidates', $decoded) || !is_array($decoded['candidates'])) { + throw new RuntimeException('Gemini image generation API returned unexpected response structure.'); + } + + // Find the first image part in the response + /** @var list $candidates */ + $candidates = $decoded['candidates']; + + foreach ($candidates as $candidate) { + if (!is_array($candidate)) { + continue; + } + + $content = $candidate['content'] ?? null; + + if (!is_array($content) || !is_array($content['parts'] ?? null)) { + continue; + } + + /** @var list $parts */ + $parts = $content['parts']; + + foreach ($parts as $part) { + if (!is_array($part) || !array_key_exists('inlineData', $part)) { + continue; + } + + $inlineData = $part['inlineData']; + + if (!is_array($inlineData) || !is_string($inlineData['data'] ?? null)) { + continue; + } + + $imageData = base64_decode($inlineData['data'], true); + + if ($imageData === false) { + throw new RuntimeException('Failed to decode base64 image data from Gemini response.'); + } + + return $imageData; + } + } + + throw new RuntimeException('Gemini image generation API returned no image data in response.'); + } + + /** + * @return array + */ + private function buildRequestBody(string $prompt, ?string $imageSize): array + { + $generationConfig = [ + 'responseModalities' => ['IMAGE', 'TEXT'], + 'responseMimeType' => 'application/json', + ]; + + if ($imageSize !== null) { + $generationConfig['imageConfig'] = [ + 'imageSize' => $imageSize, + ]; + } + + return [ + 'contents' => [ + [ + 'parts' => [ + ['text' => $prompt], + ], + ], + ], + 'generationConfig' => $generationConfig, + ]; + } +} diff --git a/src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorFactory.php b/src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorFactory.php new file mode 100644 index 0000000..112aab7 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorFactory.php @@ -0,0 +1,28 @@ + $this->openAiImageGenerator, + LlmModelProvider::Google => $this->geminiImageGenerator, + }; + } +} diff --git a/src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorInterface.php b/src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorInterface.php new file mode 100644 index 0000000..b58809a --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/ImageGeneratorInterface.php @@ -0,0 +1,20 @@ + */ + private array $collectedPrompts = []; + + public function __construct( + private readonly string $apiKey, + private readonly string $pageHtml, + private readonly int $imageCount, + private readonly LlmModelProvider $llmProvider = LlmModelProvider::OpenAI, + private readonly ?HandlerStack $guzzleHandlerStack = null, + ) { + } + + protected function provider(): AIProviderInterface + { + $model = $this->llmProvider->imagePromptGenerationModel()->value; + $httpOptions = null; + + if ($this->guzzleHandlerStack !== null) { + $httpOptions = new HttpClientOptions( + null, + null, + null, + $this->guzzleHandlerStack, + ); + } + + return match ($this->llmProvider) { + LlmModelProvider::OpenAI => new OpenAI( + $this->apiKey, + $model, + [], + false, + $httpOptions, + ), + LlmModelProvider::Google => new PatchedGemini( + $this->apiKey, + $model, + [], + $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" + . 'Important: The language used for the prompts must match the language of the user interface of the web page!' + . "\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 .png extension) that clearly describes " + . 'what the image shows (e.g. "modern-office-team-collaborating.png", not "office.png" ' + . 'or "image1.png")', + $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 .png extension (e.g. "cozy-cafe-winter-scene.png").', + true + ) + ) + ->setCallable(function (string $prompt, string $file_name): string { + $this->collectedPrompts[] = new ImagePromptResultDto($prompt, $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..d147a09 --- /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, + 'output_format' => 'png', + ], + ]); + + $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 (!array_key_exists(0, $result['data'])) { + 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..3142889 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/OpenAiPromptGenerator.php @@ -0,0 +1,60 @@ + + */ + public function generatePrompts( + string $pageHtml, + string $userPrompt, + string $apiKey, + int $count, + LlmModelProvider $provider = LlmModelProvider::OpenAI, + ): array { + $agent = new ImagePromptAgent( + $apiKey, + $pageHtml, + $count, + $provider, + $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/PatchedGemini.php b/src/PhotoBuilder/Infrastructure/Adapter/PatchedGemini.php new file mode 100644 index 0000000..9a28950 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/PatchedGemini.php @@ -0,0 +1,228 @@ + + */ + private static array $blockedFinishReasons = [ + 'SAFETY', + 'BLOCKLIST', + 'OTHER', + 'RECITATION', + ]; + + /** + * @param list $messages + */ + public function chat(array $messages): Message + { + /** @var Message $message */ + $message = $this->patchedChatAsync($messages)->wait(); + + return $message; + } + + /** + * Overrides the HandleChat trait to scan ALL parts for functionCall, + * not just parts[0]. + * + * @param list $messages + */ + public function chatAsync(array $messages): PromiseInterface + { + return $this->patchedChatAsync($messages); + } + + /** + * @param list $messages + */ + private function patchedChatAsync(array $messages): PromiseInterface + { + $json = [ + 'contents' => $this->messageMapper()->map($messages), + ...$this->parameters, + ]; + + if ($this->system !== null) { + $json['system_instruction'] = [ + 'parts' => [ + ['text' => $this->system], + ], + ]; + } + + if ($this->tools !== []) { + $json['tools'] = $this->toolPayloadMapper()->map($this->tools); + } + + return $this->client->postAsync( + trim($this->baseUri, '/') . "/{$this->model}:generateContent", + [RequestOptions::JSON => $json], + ) + ->then(function (ResponseInterface $response): Message { + return $this->parseGeminiResponse($response); + }); + } + + /** + * Overrides parent to reindex the tools array after filtering. + * + * The original uses array_filter() which preserves array keys, causing + * gaps when text/thought parts precede functionCall parts. + * + * @param array $message + */ + protected function createToolCallMessage(array $message): Message // @phpstan-ignore noAssociativeArraysAcrossBoundaries.param + { + $signature = null; + + /** @var list> $messageParts */ + $messageParts = $message['parts']; + + $tools = array_map(function (array $item) use (&$signature): ?ToolInterface { + if (!array_key_exists('functionCall', $item)) { + return null; + } + + /** @var string|false $sig */ + $sig = $item['thoughtSignature'] ?? false; + if ($sig !== false) { + $signature = $sig; + } + + /** @var array{name: string, args: array} $functionCall */ + $functionCall = $item['functionCall']; + + return $this->findTool($functionCall['name']) + ->setInputs($functionCall['args']) + ->setCallId($functionCall['name']); + }, $messageParts); + + /** @var string|null $messageContent */ + $messageContent = $message['content'] ?? null; + + // array_values() reindexes the array so tools start at index 0 + $result = new ToolCallMessage( + $messageContent, + array_values(array_filter($tools)), + ); + + if ($signature !== null) { + /** @var string $signatureValue */ + $signatureValue = $signature; + $result->addMetadata('thoughtSignature', $signatureValue); + } + + return $result; + } + + private function parseGeminiResponse(ResponseInterface $response): Message + { + /** @var array|null $result */ + $result = json_decode($response->getBody()->getContents(), true); + + if ($result === null || empty($result['candidates'])) { + throw new ProviderException( + 'Gemini API returned no candidates. Response: ' . json_encode($result) + ); + } + + /** @var list> $candidates */ + $candidates = $result['candidates']; + $candidate = $candidates[0]; + $finishReason = is_string($candidate['finishReason'] ?? null) + ? $candidate['finishReason'] + : 'UNKNOWN'; + + /** @var array $content */ + $content = $candidate['content'] ?? []; + + if (empty($content['parts'])) { + if (in_array($finishReason, self::$blockedFinishReasons, true)) { + throw new ProviderException( + "Gemini response blocked (finishReason: {$finishReason}). " + . 'This may be transient - retry recommended.' + ); + } + + return new AssistantMessage(''); + } + + /** @var list> $parts */ + $parts = $content['parts']; + + // FIX: Scan ALL parts for functionCall, not just parts[0]. + // Gemini 3 models may return text/thought parts before functionCall parts. + $hasFunctionCall = false; + foreach ($parts as $part) { + if (array_key_exists('functionCall', $part) && $part['functionCall'] !== []) { + $hasFunctionCall = true; + + break; + } + } + + if ($hasFunctionCall) { + $parsedResponse = $this->createToolCallMessage($content); + } else { + /** @var string $textContent */ + $textContent = $parts[0]['text'] ?? ''; + $parsedResponse = new AssistantMessage($textContent); + } + + if (array_key_exists('groundingMetadata', $candidate)) { + /** @var string $groundingMetadata */ + $groundingMetadata = $candidate['groundingMetadata']; + $parsedResponse->addMetadata('groundingMetadata', $groundingMetadata); + } + + if (array_key_exists('usageMetadata', $result)) { + /** @var array{promptTokenCount: int, candidatesTokenCount?: int} $usageMeta */ + $usageMeta = $result['usageMetadata']; + $parsedResponse->setUsage( + new Usage( + $usageMeta['promptTokenCount'], + $usageMeta['candidatesTokenCount'] ?? 0, + ) + ); + } + + return $parsedResponse; + } +} diff --git a/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php b/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php new file mode 100644 index 0000000..5229ad4 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Adapter/PromptGeneratorInterface.php @@ -0,0 +1,27 @@ + + */ + public function generatePrompts( + string $pageHtml, + string $userPrompt, + string $apiKey, + int $count, + LlmModelProvider $provider = LlmModelProvider::OpenAI, + ): array; +} diff --git a/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php b/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php new file mode 100644 index 0000000..373ee72 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Handler/GenerateImageHandler.php @@ -0,0 +1,113 @@ +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->getEffectivePhotoBuilderApiKey() === '') { + $image->setStatus(PhotoImageStatus::Failed); + $image->setErrorMessage('No LLM API key configured for project.'); + $this->entityManager->flush(); + $this->updateSessionStatus($session); + + return; + } + + // Generate image (or fake if simulation is enabled) + $simulate = $_ENV['PHOTO_BUILDER_SIMULATE_IMAGE_GENERATION'] ?? '0'; + $provider = $project->getEffectivePhotoBuilderLlmModelProvider(); + $generator = $simulate === '1' + ? new FakeImageGenerator($this->logger) + : $this->imageGeneratorFactory->create($provider); + + $imageData = $generator->generateImage( + $image->getPrompt() ?? '', + $project->getEffectivePhotoBuilderApiKey(), + $message->imageSize, + ); + + // Store on disk + $sessionId = $session->getId(); + + if ($sessionId === null) { + throw new RuntimeException('PhotoSession has no ID.'); + } + + $storagePath = $this->imageStorage->save( + $sessionId, + $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..822ebf1 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Handler/GenerateImagePromptsHandler.php @@ -0,0 +1,112 @@ +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->getEffectivePhotoBuilderApiKey() === '') { + $this->logger->error('No LLM API key configured for project (PhotoBuilder)', [ + '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 (or fake if simulation is enabled) + $generator = $_ENV['PHOTO_BUILDER_SIMULATE_IMAGE_PROMPT_GENERATION'] ?? '0'; + $promptGenerator = $generator === '1' + ? new FakePromptGenerator($this->logger) + : $this->promptGenerator; + + $promptResults = $promptGenerator->generatePrompts( + $pageHtml, + $session->getUserPrompt(), + $project->getEffectivePhotoBuilderApiKey(), + PhotoBuilderService::IMAGE_COUNT, + $project->getEffectivePhotoBuilderLlmModelProvider(), + ); + + // Update image entities with generated prompts (skips images in keepImageIds) + $changedImages = $this->photoBuilderService->updateImagePrompts( + $session, + $promptResults, + $message->keepImageIds, + ); + + $session->setStatus(PhotoSessionStatus::PromptsReady); + $this->entityManager->flush(); + + // Dispatch image generation only for images whose prompts were changed + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $this->entityManager->flush(); + + foreach ($changedImages as $image) { + $imageId = $image->getId(); + + if ($imageId !== null && $image->getPrompt() !== null && $image->getPrompt() !== '') { + $this->messageBus->dispatch(new GenerateImageMessage($imageId)); + } + } + } 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..62eea02 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Message/GenerateImageMessage.php @@ -0,0 +1,19 @@ + $keepImageIds Image IDs whose prompts must not be regenerated (Keep prompt checked) + */ +final readonly class GenerateImagePromptsMessage implements ImmediateSymfonyMessageInterface +{ + /** + * @param list $keepImageIds + */ + public function __construct( + public string $sessionId, + public string $locale, + public array $keepImageIds = [], + ) { + } +} diff --git a/src/PhotoBuilder/Infrastructure/Storage/GeneratedImageStorage.php b/src/PhotoBuilder/Infrastructure/Storage/GeneratedImageStorage.php new file mode 100644 index 0000000..85918f9 --- /dev/null +++ b/src/PhotoBuilder/Infrastructure/Storage/GeneratedImageStorage.php @@ -0,0 +1,86 @@ +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/src/PhotoBuilder/Presentation/Controller/PhotoBuilderController.php b/src/PhotoBuilder/Presentation/Controller/PhotoBuilderController.php new file mode 100644 index 0000000..985237a --- /dev/null +++ b/src/PhotoBuilder/Presentation/Controller/PhotoBuilderController.php @@ -0,0 +1,619 @@ +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; + + $effectiveProvider = $project->getEffectivePhotoBuilderLlmModelProvider(); + + return $this->render('@photo_builder.presentation/photo_builder.twig', [ + 'workspace' => $workspace, + 'project' => $project, + 'pagePath' => $pagePath, + 'conversationId' => $conversationId, + 'imageCount' => PhotoBuilderService::IMAGE_COUNT, + 'hasRemoteAssets' => $hasRemoteAssets, + 'effectivePhotoBuilderProvider' => $effectiveProvider->displayName(), + 'imagePromptModel' => $effectiveProvider->imagePromptGenerationModel()->value, + 'imageGenerationModel' => $effectiveProvider->imageGenerationModel()->value, + 'supportsResolutionToggle' => $effectiveProvider === LlmModelProvider::Google, + ]); + } + + /** + * 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( + fn (PhotoImage $image): array => [ + 'id' => $image->getId(), + 'position' => $image->getPosition(), + 'prompt' => $image->getPrompt(), + 'suggestedFileName' => $image->getSuggestedFileName(), + 'status' => $image->getStatus()->value, + 'imageUrl' => $image->getStoragePath() !== null + ? $this->generateUrl('photo_builder.presentation.serve_image', [ + 'imageId' => $image->getId(), + ]) + : null, + 'errorMessage' => $image->getErrorMessage(), + 'uploadedToMediaStore' => $image->getUploadedToMediaStoreAt() !== null, + 'uploadedFileName' => $image->getUploadedFileName(), + ], + $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); + $keepIds = []; + + if (is_array($data)) { + $userPrompt = is_string($data['userPrompt'] ?? null) ? $data['userPrompt'] : ''; + + if ($userPrompt !== '') { + $session->setUserPrompt($userPrompt); + } + + $keepImageIds = $data['keepImageIds'] ?? null; + if (is_array($keepImageIds)) { + foreach ($keepImageIds as $id) { + if (is_string($id) && $id !== '') { + $keepIds[] = $id; + } + } + } + } + + $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(), + $keepIds, + )); + + 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); + } + + $data = json_decode($request->getContent(), true); + $imageSize = is_array($data) && is_string($data['imageSize'] ?? null) ? $data['imageSize'] : null; + + $image->setStatus(PhotoImageStatus::Pending); + $image->setStoragePath(null); + $image->setErrorMessage(null); + $image->setUploadedToMediaStoreAt(null); + $image->setUploadedFileName(null); + + $session = $image->getSession(); + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $this->entityManager->flush(); + + $this->messageBus->dispatch(new GenerateImageMessage($imageId, $imageSize)); + + return $this->json(['status' => 'ok']); + } + + /** + * Regenerate all images in a session (e.g. after resolution change). + */ + #[Route( + path: '/api/photo-builder/sessions/{sessionId}/regenerate-all-images', + name: 'photo_builder.presentation.regenerate_all_images', + methods: [Request::METHOD_POST], + requirements: ['sessionId' => '[a-f0-9-]{36}'] + )] + public function regenerateAllImages( + 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); + $imageSize = is_array($data) && is_string($data['imageSize'] ?? null) ? $data['imageSize'] : null; + + $session->setStatus(PhotoSessionStatus::GeneratingImages); + + foreach ($session->getImages() as $image) { + $imgId = $image->getId(); + + if ($imgId === null || $image->getPrompt() === null || $image->getPrompt() === '') { + continue; + } + + $image->setStatus(PhotoImageStatus::Pending); + $image->setStoragePath(null); + $image->setErrorMessage(null); + $image->setUploadedToMediaStoreAt(null); + $image->setUploadedFileName(null); + } + + $this->entityManager->flush(); + + foreach ($session->getImages() as $image) { + $imgId = $image->getId(); + + if ($imgId === null || $image->getPrompt() === null || $image->getPrompt() === '') { + continue; + } + + $this->messageBus->dispatch(new GenerateImageMessage($imgId, $imageSize)); + } + + 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); + } + + $fileName = $image->getSuggestedFileName() ?? 'generated-image-' . $image->getPosition() . '.png'; + + if ($image->getUploadedToMediaStoreAt() !== null) { + return $this->json([ + 'url' => '', + 'fileName' => $fileName, + 'uploadedFileName' => $image->getUploadedFileName(), + ]); + } + + $imageData = $this->imageStorage->read($image->getStoragePath()); + $uploadedUrl = $this->remoteContentAssetsFacade->uploadAsset( + $project->s3BucketName, + $project->s3Region, + $project->s3AccessKeyId, + $project->s3SecretAccessKey, + $project->s3IamRoleArn, + $project->s3KeyPrefix, + $fileName, + $imageData, + 'image/png', + ); + + $uploadedFileName = $this->extractFilenameFromUrl($uploadedUrl); + $image->setUploadedToMediaStoreAt(DateAndTimeService::getDateTimeImmutable()); + $image->setUploadedFileName($uploadedFileName); + $this->entityManager->flush(); + + return $this->json([ + 'url' => $uploadedUrl, + 'fileName' => $fileName, + 'uploadedFileName' => $uploadedFileName, + ]); + } + + /** + * Check whether uploaded filenames are available in the remote asset manifests. + */ + #[Route( + path: '/api/photo-builder/{workspaceId}/check-manifest-availability', + name: 'photo_builder.presentation.check_manifest_availability', + methods: [Request::METHOD_POST], + requirements: ['workspaceId' => '[a-f0-9-]{36}'] + )] + public function checkManifestAvailability( + string $workspaceId, + Request $request, + #[CurrentUser] UserInterface $user, + ): JsonResponse { + $this->getAccountInfo($user); + + [$workspace, $project] = $this->loadWorkspaceAndProject($workspaceId); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->json(['error' => 'Invalid JSON body.'], Response::HTTP_BAD_REQUEST); + } + + $rawFileNames = $data['fileNames'] ?? null; + + if (!is_array($rawFileNames)) { + return $this->json(['error' => 'Missing fileNames array.'], Response::HTTP_BAD_REQUEST); + } + + /** @var list $fileNames */ + $fileNames = []; + foreach ($rawFileNames as $name) { + if (is_string($name) && $name !== '') { + $fileNames[] = $name; + } + } + + if ($fileNames === []) { + return $this->json([ + 'available' => [], + 'allAvailable' => true, + ]); + } + + $available = $this->remoteContentAssetsFacade->findAvailableFileNames( + $project->remoteContentAssetsManifestUrls, + $fileNames + ); + + return $this->json([ + 'available' => $available, + 'allAvailable' => count($available) === count($fileNames), + ]); + } + + private function extractFilenameFromUrl(string $url): string + { + $path = parse_url($url, PHP_URL_PATH); + + return $path !== null && $path !== false ? basename($path) : ''; + } +} 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..5bf9358 --- /dev/null +++ b/src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_builder_controller.ts @@ -0,0 +1,683 @@ +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; + uploadedToMediaStore?: boolean; + uploadedFileName?: string | null; +} + +interface PromptEditedDetail { + position: number; + prompt: string; +} + +interface RegenerateRequestedDetail { + position: number; + imageId: string; + prompt: string; +} + +interface UploadRequestedDetail { + position: number; + imageId: string; + suggestedFileName: string; +} + +const SESSION_ID_PLACEHOLDER = "00000000-0000-0000-0000-000000000000"; +const IMAGE_ID_PLACEHOLDER = "00000000-0000-0000-0000-111111111111"; +const PROMPT_DEBOUNCE_MS = 400; +const POLL_INTERVAL_ACTIVE_MS = 1000; +const POLL_INTERVAL_IDLE_MS = 5000; + +function imageDataEqual(a: ImageData, b: ImageData): boolean { + return ( + a.id === b.id && + a.position === b.position && + a.status === b.status && + (a.imageUrl ?? null) === (b.imageUrl ?? null) && + (a.prompt ?? null) === (b.prompt ?? null) && + (a.suggestedFileName ?? null) === (b.suggestedFileName ?? null) && + (a.errorMessage ?? null) === (b.errorMessage ?? null) && + (a.uploadedToMediaStore ?? false) === (b.uploadedToMediaStore ?? false) + ); +} + +/** + * 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, + regenerateAllImagesUrlPattern: String, + updatePromptUrlPattern: String, + uploadToMediaStoreUrlPattern: String, + checkManifestAvailabilityUrlPattern: String, + csrfToken: String, + workspaceId: String, + pagePath: String, + conversationId: String, + imageCount: Number, + defaultUserPrompt: String, + editorUrl: String, + hasRemoteAssets: Boolean, + supportsResolutionToggle: Boolean, + embedPrefillMessage: { type: String, default: "Embed images %fileNames% into page %pagePath%" }, + }; + + static targets = [ + "loadingOverlay", + "mainContent", + "userPrompt", + "regeneratePromptsButton", + "embedButton", + "imageCard", + "regeneratingPromptsOverlay", + "uploadingImagesOverlay", + "waitingForManifestOverlay", + "resolutionToggle", + "loresButton", + "hiresButton", + ]; + + declare readonly createSessionUrlValue: string; + declare readonly pollUrlPatternValue: string; + declare readonly regeneratePromptsUrlPatternValue: string; + declare readonly regenerateImageUrlPatternValue: string; + declare readonly regenerateAllImagesUrlPatternValue: string; + declare readonly updatePromptUrlPatternValue: string; + declare readonly uploadToMediaStoreUrlPatternValue: string; + declare readonly checkManifestAvailabilityUrlPatternValue: 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 supportsResolutionToggleValue: boolean; + declare readonly embedPrefillMessageValue: string; + + 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[]; + declare readonly hasRegeneratingPromptsOverlayTarget: boolean; + declare readonly regeneratingPromptsOverlayTarget: HTMLElement; + declare readonly hasUploadingImagesOverlayTarget: boolean; + declare readonly uploadingImagesOverlayTarget: HTMLElement; + declare readonly hasWaitingForManifestOverlayTarget: boolean; + declare readonly waitingForManifestOverlayTarget: HTMLElement; + declare readonly hasResolutionToggleTarget: boolean; + declare readonly resolutionToggleTarget: HTMLElement; + declare readonly hasLoresButtonTarget: boolean; + declare readonly loresButtonTarget: HTMLButtonElement; + declare readonly hasHiresButtonTarget: boolean; + declare readonly hiresButtonTarget: HTMLButtonElement; + + private sessionId: string | null = null; + private isRegeneratingPrompts = false; + private pollingTimeoutId: ReturnType | null = null; + private isActive = false; + private anyGenerating = false; + private lastImages: ImageData[] = []; + /** Current image size: "1K" = lo-res (default), "2K" = hi-res. */ + private currentImageSize: string = "1K"; + /** Last userPrompt we applied from the server; used to avoid overwriting local edits on poll. */ + private lastAppliedUserPrompt: string | null = null; + /** Debounce timeouts per imageId for prompt update API calls. */ + private promptDebounceTimeouts: Record> = {}; + /** Last session status from poll; used to choose active vs idle poll interval. */ + private lastPollStatus: string | null = null; + + connect(): void { + this.isActive = true; + this.createSession(); + } + + disconnect(): void { + this.isActive = false; + this.stopPolling(); + if (this.promptDebounceTimeouts) { + for (const id of Object.keys(this.promptDebounceTimeouts)) { + clearTimeout(this.promptDebounceTimeouts[id]); + } + this.promptDebounceTimeouts = {}; + } + } + + private stopPolling(): void { + if (this.pollingTimeoutId !== null) { + clearTimeout(this.pollingTimeoutId); + this.pollingTimeoutId = null; + } + } + + /** + * Schedule the next poll. Uses active interval (1s) when generating, idle interval (5s) when session is images_ready and nothing is generating. + * Pass explicit intervalMs to force a specific interval (e.g. when user triggers an action and we want to poll soon). + */ + private scheduleNextPoll(intervalMs?: number): void { + if (!this.isActive || !this.sessionId) return; + const idle = (this.lastPollStatus === "images_ready" && !this.anyGenerating) || false; + const interval = intervalMs ?? (idle ? POLL_INTERVAL_IDLE_MS : POLL_INTERVAL_ACTIVE_MS); + this.pollingTimeoutId = setTimeout(() => this.poll(), interval); + } + + /** Switch to active (1s) polling immediately; call when user triggers an action that may change state. */ + private startActivePolling(): void { + this.stopPolling(); + this.scheduleNextPoll(POLL_INTERVAL_ACTIVE_MS); + } + + 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_PLACEHOLDER, 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; + this.lastPollStatus = status; + const images = data.images || []; + const prevImages = this.lastImages; + this.lastImages = images; + + // Hide regenerating-prompts overlay once backend has accepted and we see generating state + if ( + this.isRegeneratingPrompts && + (status === "generating_prompts" || + status === "generating_images" || + images.some((img) => img.status === "generating" || img.status === "pending")) + ) { + this.hideRegeneratingPromptsOverlay(); + } + + // Check if any image is currently generating + const prevAnyGenerating = this.anyGenerating; + 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 from server only when not focused and not overwriting user edits + if ( + data.userPrompt && + document.activeElement !== this.userPromptTarget && + (this.lastAppliedUserPrompt === null || this.userPromptTarget.value === this.lastAppliedUserPrompt) + ) { + this.userPromptTarget.value = data.userPrompt; + this.lastAppliedUserPrompt = data.userPrompt; + } + + // Update button states + this.updateButtonStates(); + + // Dispatch state changes only to cards whose image data actually changed, + // but force-dispatch to all cards when the generating state transitions + // (children read data-photo-builder-generating to enable/disable buttons). + const generatingChanged = prevAnyGenerating !== this.anyGenerating; + for (const image of images) { + const card = this.imageCardTargets[image.position]; + if (!card) continue; + const prev = prevImages[image.position]; + if (prev && imageDataEqual(prev, image) && !generatingChanged) continue; + card.dispatchEvent( + new CustomEvent("photo-builder:stateChanged", { + detail: image, + bubbles: false, + }), + ); + } + } + + private updateButtonStates(): void { + const generating = this.anyGenerating || this.isRegeneratingPrompts; + + // Disable regenerate prompts button while generating or regenerating prompts + if (this.regeneratePromptsButtonTarget) { + this.regeneratePromptsButtonTarget.disabled = generating; + } + + // Disable embed button while generating + if (this.hasEmbedButtonTarget) { + this.embedButtonTarget.disabled = generating; + } + + // Toggle a data attribute on the container for child controllers + this.element.setAttribute("data-photo-builder-generating", generating ? "true" : "false"); + } + + /** + * Handle "Regenerate image prompts" button click. + */ + async regeneratePrompts(): Promise { + if (!this.sessionId || this.anyGenerating) return; + + this.startActivePolling(); + + // Tell child cards to clear prompt textarea if not kept (shows pulsing immediately) + for (const card of this.imageCardTargets) { + card.dispatchEvent(new CustomEvent("photo-builder:clearPromptIfNotKept", { bubbles: false })); + } + + this.isRegeneratingPrompts = true; + this.updateButtonStates(); + + // 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_PLACEHOLDER, 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; overlay is hidden in handlePollResponse + } catch { + this.hideRegeneratingPromptsOverlay(); + } + } + + private hideRegeneratingPromptsOverlay(): void { + this.isRegeneratingPrompts = false; + if (this.hasRegeneratingPromptsOverlayTarget) { + this.regeneratingPromptsOverlayTarget.classList.add("hidden"); + } + } + + /** + * Handle photo-image:promptEdited event from child. + * Debounces so rapid keystrokes result in a single API call per image after typing stops. + */ + handlePromptEdited(event: CustomEvent): void { + const { imageId, prompt } = event.detail as unknown as { + imageId: string; + prompt: string; + }; + if (!imageId) return; + + const timeouts = this.promptDebounceTimeouts ?? {}; + if (timeouts[imageId]) { + clearTimeout(timeouts[imageId]); + } + this.promptDebounceTimeouts = timeouts; + this.promptDebounceTimeouts[imageId] = setTimeout(() => { + delete this.promptDebounceTimeouts![imageId]; + const url = this.updatePromptUrlPatternValue.replace(IMAGE_ID_PLACEHOLDER, imageId); + fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfTokenValue, + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ prompt }), + }).catch(() => { + // Silently ignore + }); + }, PROMPT_DEBOUNCE_MS); + } + + /** + * Handle photo-image:regenerateRequested event from child. + */ + async handleRegenerateImage(event: CustomEvent): Promise { + const { imageId } = event.detail; + if (!imageId || this.anyGenerating) return; + + this.startActivePolling(); + + try { + const url = this.regenerateImageUrlPatternValue.replace(IMAGE_ID_PLACEHOLDER, imageId); + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfTokenValue, + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ imageSize: this.currentImageSize }), + }); + } catch { + // Silently ignore + } + } + + /** + * Handle resolution toggle (lo-res / hi-res) button click. + * Re-generates all images at the new resolution without reloading the page. + */ + async switchResolution(event: Event): Promise { + if (!this.sessionId || this.anyGenerating) return; + + this.startActivePolling(); + + const target = event.currentTarget as HTMLElement; + const newSize = target.dataset.imageSize ?? this.currentImageSize; + if (newSize === this.currentImageSize) return; + + this.currentImageSize = newSize; + this.updateResolutionToggleUi(); + + try { + const url = this.regenerateAllImagesUrlPatternValue.replace(SESSION_ID_PLACEHOLDER, this.sessionId); + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfTokenValue, + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ imageSize: this.currentImageSize }), + }); + // Polling will pick up the regeneration + } catch { + // Silently ignore + } + } + + private updateResolutionToggleUi(): void { + if (!this.hasLoresButtonTarget || !this.hasHiresButtonTarget) return; + + const activeClasses = ["bg-primary-600", "text-white", "shadow-sm"]; + const inactiveClasses = [ + "text-dark-600", + "dark:text-dark-400", + "hover:text-dark-900", + "dark:hover:text-dark-200", + ]; + + const isLores = this.currentImageSize === "1K"; + if (isLores) { + this.loresButtonTarget.classList.add(...activeClasses); + this.loresButtonTarget.classList.remove(...inactiveClasses); + this.hiresButtonTarget.classList.remove(...activeClasses); + this.hiresButtonTarget.classList.add(...inactiveClasses); + } else { + this.hiresButtonTarget.classList.add(...activeClasses); + this.hiresButtonTarget.classList.remove(...inactiveClasses); + this.loresButtonTarget.classList.remove(...activeClasses); + this.loresButtonTarget.classList.add(...inactiveClasses); + } + } + + /** + * Upload a single image to the media store. + * Returns the actual S3 filename (hash-prefixed) on success, null on failure. + */ + private async uploadImageToMediaStore(imageId: string): Promise { + try { + const url = this.uploadToMediaStoreUrlPatternValue.replace(IMAGE_ID_PLACEHOLDER, 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) { + return null; + } + const data = (await response.json()) as { uploadedFileName?: string }; + return data.uploadedFileName ?? null; + } catch { + return null; + } + } + + /** + * Handle photo-image:uploadRequested event from child. + */ + async handleUploadToMediaStore(event: CustomEvent): Promise { + const { imageId } = event.detail; + if (!imageId) return; + + const cardFromEvent = + event.target instanceof HTMLElement + ? (event.target.closest("[data-photo-builder-target='imageCard']") as HTMLElement | null) + : null; + const uploadedFileName = await this.uploadImageToMediaStore(imageId); + const card = + cardFromEvent ?? + this.imageCardTargets.find((el) => el.getAttribute("data-photo-image-image-id") === imageId); + if (uploadedFileName !== null) { + if (card) { + card.dispatchEvent( + new CustomEvent("photo-builder:uploadComplete", { + detail: { imageId }, + bubbles: true, + }), + ); + } + } else { + if (card) { + card.dispatchEvent( + new CustomEvent("photo-builder:uploadFailed", { + detail: { imageId }, + bubbles: true, + }), + ); + } + } + } + + /** + * Handle remote-asset-browser:uploadComplete event (e.g. upload via sidebar dropzone). + */ + handleMediaStoreUploadComplete(): void {} + + /** + * Navigate back to editor with pre-filled embed message. + * Uploads any completed images that are not yet on the media store first, + * then waits for them to appear in a remote asset manifest before redirecting. + */ + async embedIntoPage(): Promise { + const completedImages = this.lastImages.filter((img) => img.suggestedFileName && img.status === "completed"); + + if (completedImages.length === 0) { + return; + } + + const imagesToUpload = completedImages.filter((img) => img.uploadedToMediaStore !== true); + + const uploadedFileNamesByImageId: Record = {}; + + if (imagesToUpload.length > 0) { + if (this.hasUploadingImagesOverlayTarget) { + this.uploadingImagesOverlayTarget.classList.remove("hidden"); + } + if (this.hasEmbedButtonTarget) { + this.embedButtonTarget.disabled = true; + } + + const results = await Promise.all( + imagesToUpload.map(async (img) => { + const fn = await this.uploadImageToMediaStore(img.id); + if (fn !== null) { + uploadedFileNamesByImageId[img.id] = fn; + } + return fn !== null; + }), + ); + + if (this.hasUploadingImagesOverlayTarget) { + this.uploadingImagesOverlayTarget.classList.add("hidden"); + } + if (this.hasEmbedButtonTarget) { + this.embedButtonTarget.disabled = this.anyGenerating; + } + + const allSucceeded = results.every(Boolean); + if (!allSucceeded) { + return; + } + } + + const allUploadedFileNames = completedImages + .map((img) => uploadedFileNamesByImageId[img.id] ?? img.uploadedFileName ?? "") + .filter(Boolean); + + // Wait for all uploaded files to appear in the remote manifest + if (allUploadedFileNames.length > 0 && this.checkManifestAvailabilityUrlPatternValue) { + await this.waitForManifestAvailability(allUploadedFileNames); + } + + const fileNames = completedImages + .map((img) => uploadedFileNamesByImageId[img.id] ?? img.uploadedFileName ?? img.suggestedFileName ?? "") + .filter(Boolean) + .join(", "); + const message = this.embedPrefillMessageValue + .replace("%fileNames%", fileNames) + .replace("%pagePath%", this.pagePathValue); + const url = `${this.editorUrlValue}?prefill=${encodeURIComponent(message)}`; + window.location.href = url; + } + + /** + * Poll the check-manifest-availability endpoint until all filenames are found + * or a timeout (~90 seconds) is reached. + */ + private async waitForManifestAvailability(fileNames: string[]): Promise { + if (this.hasWaitingForManifestOverlayTarget) { + this.waitingForManifestOverlayTarget.classList.remove("hidden"); + } + if (this.hasEmbedButtonTarget) { + this.embedButtonTarget.disabled = true; + } + + const pollIntervalMs = 3000; + const maxAttempts = 30; // ~90 seconds + const url = this.checkManifestAvailabilityUrlPatternValue; + + let allAvailable = false; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": this.csrfTokenValue, + "X-Requested-With": "XMLHttpRequest", + }, + body: JSON.stringify({ fileNames }), + }); + + if (response.ok) { + const data = (await response.json()) as { allAvailable?: boolean }; + if (data.allAvailable === true) { + allAvailable = true; + break; + } + } + } catch { + // Ignore individual poll errors, keep trying + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + if (this.hasWaitingForManifestOverlayTarget) { + this.waitingForManifestOverlayTarget.classList.add("hidden"); + } + if (this.hasEmbedButtonTarget) { + this.embedButtonTarget.disabled = this.anyGenerating; + } + + return allAvailable; + } +} 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..178842c --- /dev/null +++ b/src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_image_controller.ts @@ -0,0 +1,292 @@ +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; + uploadedToMediaStore?: boolean; +} + +/** + * 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, + generatingPromptText: { type: String, default: "Generating..." }, + }; + + static targets = [ + "image", + "placeholder", + "promptTextarea", + "keepCheckbox", + "regenerateButton", + "uploadButton", + "uploadButtonDefault", + "uploadButtonUploading", + "uploadButtonSuccess", + "statusBadge", + ]; + + declare readonly positionValue: number; + declare readonly hasMediaStoreValue: boolean; + declare readonly generatingPromptTextValue: string; + + 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 hasUploadButtonDefaultTarget: boolean; + declare readonly uploadButtonDefaultTarget: HTMLElement; + declare readonly hasUploadButtonUploadingTarget: boolean; + declare readonly uploadButtonUploadingTarget: HTMLElement; + declare readonly hasUploadButtonSuccessTarget: boolean; + declare readonly uploadButtonSuccessTarget: HTMLElement; + declare readonly statusBadgeTarget: HTMLElement; + + private imageId: string | null = null; + private currentStatus = "pending"; + private suggestedFileName: string | null = null; + private promptAwaitingRegenerate = false; + /** The prompt value before regeneration was requested; used to detect when a genuinely new prompt arrives. */ + private promptBeforeRegenerate: string | null = null; + private uploadInProgress = false; + private uploadJustSucceeded = false; + private uploadedToMediaStore = false; + /** Last image URL we set on the img element (without query string), to avoid re-requesting the same image. */ + private lastSetImageUrl: 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; + this.uploadedToMediaStore = data.uploadedToMediaStore ?? false; + + // Store imageId on the element for parent to read + this.element.setAttribute("data-photo-image-image-id", data.id); + + // Update prompt textarea + if (document.activeElement !== this.promptTextareaTarget) { + if (this.promptAwaitingRegenerate) { + // Apply prompt once it differs from the pre-regeneration value + // (regardless of status — the image may already be "completed" if generation was fast). + if (data.prompt !== null && data.prompt !== "" && data.prompt !== this.promptBeforeRegenerate) { + this.promptTextareaTarget.value = data.prompt; + this.promptTextareaTarget.classList.remove("animate-pulse"); + this.promptAwaitingRegenerate = false; + this.promptBeforeRegenerate = null; + } + } else if (data.prompt !== null) { + 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(); + + if (this.hasUploadButtonDefaultTarget) { + this.updateUploadButtonState(); + } + } + + private updateImageDisplay(data: ImageStateDetail): void { + if (data.status === "completed" && data.imageUrl) { + const baseUrl = data.imageUrl.split("?")[0]; + if (this.lastSetImageUrl !== baseUrl) { + this.imageTarget.src = baseUrl + "?v=" + Date.now(); + this.lastSetImageUrl = baseUrl; + } + this.imageTarget.classList.remove("hidden"); + this.placeholderTarget.classList.add("hidden"); + } else if (data.status === "generating" || data.status === "pending") { + this.lastSetImageUrl = null; + this.imageTarget.classList.add("hidden"); + this.placeholderTarget.classList.remove("hidden"); + } else if (data.status === "failed") { + this.lastSetImageUrl = null; + 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" || + this.uploadInProgress || + this.uploadedToMediaStore; + } + } + + private updateUploadButtonState(): void { + if ( + !this.hasUploadButtonDefaultTarget || + !this.hasUploadButtonUploadingTarget || + !this.hasUploadButtonSuccessTarget + ) { + return; + } + const showDefault = !this.uploadInProgress && !this.uploadJustSucceeded && !this.uploadedToMediaStore; + const showUploading = this.uploadInProgress; + const showSuccess = this.uploadJustSucceeded || this.uploadedToMediaStore; + + this.uploadButtonDefaultTarget.classList.toggle("hidden", !showDefault); + this.uploadButtonUploadingTarget.classList.toggle("hidden", !showUploading); + this.uploadButtonSuccessTarget.classList.toggle("hidden", !showSuccess); + } + + /** + * Handle photo-builder:uploadComplete event from parent. + */ + onUploadComplete(event: CustomEvent<{ imageId: string }>): void { + if (event.detail.imageId !== this.imageId) { + return; + } + this.uploadInProgress = false; + this.uploadJustSucceeded = true; + this.updateButtonStates(); + this.updateUploadButtonState(); + const hideSuccessAfterMs = 3000; + setTimeout(() => { + this.uploadJustSucceeded = false; + this.updateUploadButtonState(); + }, hideSuccessAfterMs); + } + + /** + * Handle photo-builder:uploadFailed event from parent. + */ + onUploadFailed(event: CustomEvent<{ imageId: string }>): void { + if (event.detail.imageId !== this.imageId) { + return; + } + this.uploadInProgress = false; + this.uploadJustSucceeded = false; + this.updateButtonStates(); + this.updateUploadButtonState(); + } + + /** + * Called by parent when "Regenerate image prompts" is clicked. + * Replaces prompt with "Generating..." if not kept, and shows pulsing. + */ + clearPromptIfNotKept(): void { + if (!this.keepCheckboxTarget.checked) { + this.promptBeforeRegenerate = this.promptTextareaTarget.value; + this.promptTextareaTarget.value = this.generatingPromptTextValue; + this.promptTextareaTarget.classList.add("animate-pulse"); + this.promptAwaitingRegenerate = true; + } + } + + /** + * 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.uploadInProgress = true; + this.updateButtonStates(); + this.updateUploadButtonState(); + + 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..f0c2f05 --- /dev/null +++ b/src/PhotoBuilder/Presentation/Resources/templates/photo_builder.twig @@ -0,0 +1,299 @@ +{% extends '@common.presentation/base_appshell.html.twig' %} + +{% block title %}{{ 'photo_builder.title'|trans }} — {{ project.name }} — {{ pagePath }}{% endblock %} + +{% block body %} +
    + + {# Header #} + + +
    + + {# Loading overlay #} +
    +
    +

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

    +
    + + {# Main content (hidden until prompts ready) #} + +
    +
    +{% endblock %} diff --git a/src/PhotoBuilder/TestHarness/FakeImageGenerator.php b/src/PhotoBuilder/TestHarness/FakeImageGenerator.php new file mode 100644 index 0000000..2ddd3c2 --- /dev/null +++ b/src/PhotoBuilder/TestHarness/FakeImageGenerator.php @@ -0,0 +1,77 @@ +logger->info('PhotoBuilder TestHarness: Generating fake placeholder image (skipping OpenAI call)'); + + $width = 512; + $height = 512; + + $image = imagecreatetruecolor($width, $height); + + if ($image === false) { + throw new RuntimeException('Failed to create placeholder image via GD.'); + } + + $bgColor = imagecolorallocate($image, 230, 230, 230); + $textColor = imagecolorallocate($image, 120, 120, 120); + + if ($bgColor === false || $textColor === false) { + imagedestroy($image); + + throw new RuntimeException('Failed to allocate colors for placeholder image.'); + } + + imagefilledrectangle($image, 0, 0, $width - 1, $height - 1, $bgColor); + + $text = 'PLACEHOLDER ' . DateAndTimeService::getDateTimeImmutable()->format('H:i:s'); + $fontSize = 5; + $textWidth = imagefontwidth($fontSize) * mb_strlen($text); + $textHeight = imagefontheight($fontSize); + $x = (int) (($width - $textWidth) / 2); + $y = (int) (($height - $textHeight) / 2); + + imagestring($image, $fontSize, $x, $y, $text, $textColor); + + ob_start(); + imagepng($image); + $pngData = ob_get_clean(); + imagedestroy($image); + + if ($pngData === false || $pngData === '') { + throw new RuntimeException('Failed to render placeholder PNG.'); + } + + sleep(1); // for realism + + return $pngData; + } +} diff --git a/src/PhotoBuilder/TestHarness/FakePromptGenerator.php b/src/PhotoBuilder/TestHarness/FakePromptGenerator.php new file mode 100644 index 0000000..10e2b3c --- /dev/null +++ b/src/PhotoBuilder/TestHarness/FakePromptGenerator.php @@ -0,0 +1,61 @@ +logger->info(sprintf( + 'PhotoBuilder TestHarness: Generating %d fake image prompts (skipping LLM call)', + $count, + )); + + $results = []; + + for ($i = 0; $i < $count; ++$i) { + $results[] = new ImagePromptResultDto( + sprintf( + 'A professional, high-quality stock photo suitable for a business website. Placeholder prompt %d of %d. ' + . DateAndTimeService::getDateTimeImmutable()->format('H:i:s'), + $i + 1, + $count, + ), + sprintf('placeholder-image-%d.png', $i + 1), + ); + } + + sleep(2); // for realism + + return $results; + } +} diff --git a/src/Prefab/Domain/Service/PrefabLoader.php b/src/Prefab/Domain/Service/PrefabLoader.php index 47e98ca..05f3ff4 100644 --- a/src/Prefab/Domain/Service/PrefabLoader.php +++ b/src/Prefab/Domain/Service/PrefabLoader.php @@ -114,7 +114,7 @@ private function parseEntry(array $item, int $index): ?PrefabDto $githubAccessKey, $llmModelProvider->value, $llmApiKey, - $keysVisible + $keysVisible, ); } } diff --git a/src/Prefab/Facade/Dto/PrefabDto.php b/src/Prefab/Facade/Dto/PrefabDto.php index 4c1d8c5..edf997e 100644 --- a/src/Prefab/Facade/Dto/PrefabDto.php +++ b/src/Prefab/Facade/Dto/PrefabDto.php @@ -14,8 +14,8 @@ public function __construct( public string $name, public string $projectLink, public string $githubAccessKey, - public string $llmModelProvider, - public string $llmApiKey, + public string $contentEditingLlmModelProvider, + public string $contentEditingLlmApiKey, public bool $keysVisible = true, ) { } diff --git a/src/ProjectMgmt/Domain/Entity/Project.php b/src/ProjectMgmt/Domain/Entity/Project.php index 264f2c6..6501ab8 100644 --- a/src/ProjectMgmt/Domain/Entity/Project.php +++ b/src/ProjectMgmt/Domain/Entity/Project.php @@ -31,8 +31,8 @@ public function __construct( string $name, string $gitUrl, string $githubToken, - LlmModelProvider $llmModelProvider, - string $llmApiKey, + LlmModelProvider $contentEditingLlmModelProvider, + string $contentEditingLlmModelProviderApiKey, ProjectType $projectType = ProjectType::DEFAULT, string $agentImage = self::DEFAULT_AGENT_IMAGE, ?string $agentBackgroundInstructions = null, @@ -40,16 +40,16 @@ public function __construct( ?string $agentOutputInstructions = null, ?array $remoteContentAssetsManifestUrls = null ) { - $this->organizationId = $organizationId; - $this->name = $name; - $this->gitUrl = $gitUrl; - $this->githubToken = $githubToken; - $this->llmModelProvider = $llmModelProvider; - $this->llmApiKey = $llmApiKey; - $this->projectType = $projectType; - $this->agentImage = $agentImage; - $this->createdAt = DateAndTimeService::getDateTimeImmutable(); - $this->remoteContentAssetsManifestUrls = $remoteContentAssetsManifestUrls !== null && $remoteContentAssetsManifestUrls !== [] ? $remoteContentAssetsManifestUrls : null; + $this->organizationId = $organizationId; + $this->name = $name; + $this->gitUrl = $gitUrl; + $this->githubToken = $githubToken; + $this->contentEditingLlmModelProvider = $contentEditingLlmModelProvider; + $this->contentEditingLlmModelProviderApiKey = $contentEditingLlmModelProviderApiKey; + $this->projectType = $projectType; + $this->agentImage = $agentImage; + $this->createdAt = DateAndTimeService::getDateTimeImmutable(); + $this->remoteContentAssetsManifestUrls = $remoteContentAssetsManifestUrls !== null && $remoteContentAssetsManifestUrls !== [] ? $remoteContentAssetsManifestUrls : null; // Initialize agent config from template if not provided $template = AgentConfigTemplate::forProjectType($projectType); @@ -170,22 +170,24 @@ public function setAgentImage(string $agentImage): void $this->agentImage = $agentImage; } + // Content Editing LLM configuration (mandatory) + #[ORM\Column( type: Types::STRING, length: 32, nullable: false, enumType: LlmModelProvider::class )] - private LlmModelProvider $llmModelProvider; + private LlmModelProvider $contentEditingLlmModelProvider; - public function getLlmModelProvider(): LlmModelProvider + public function getContentEditingLlmModelProvider(): LlmModelProvider { - return $this->llmModelProvider; + return $this->contentEditingLlmModelProvider; } - public function setLlmModelProvider(LlmModelProvider $llmModelProvider): void + public function setContentEditingLlmModelProvider(LlmModelProvider $contentEditingLlmModelProvider): void { - $this->llmModelProvider = $llmModelProvider; + $this->contentEditingLlmModelProvider = $contentEditingLlmModelProvider; } #[ORM\Column( @@ -193,16 +195,69 @@ public function setLlmModelProvider(LlmModelProvider $llmModelProvider): void length: 1024, nullable: false )] - private string $llmApiKey; + private string $contentEditingLlmModelProviderApiKey; + + public function getContentEditingLlmModelProviderApiKey(): string + { + return $this->contentEditingLlmModelProviderApiKey; + } + + public function setContentEditingLlmModelProviderApiKey(string $contentEditingLlmModelProviderApiKey): void + { + $this->contentEditingLlmModelProviderApiKey = $contentEditingLlmModelProviderApiKey; + } + + // PhotoBuilder LLM configuration (nullable = falls back to content editing settings) + + #[ORM\Column( + type: Types::STRING, + length: 32, + nullable: true, + enumType: LlmModelProvider::class + )] + private ?LlmModelProvider $photoBuilderLlmModelProvider = null; + + public function getPhotoBuilderLlmModelProvider(): ?LlmModelProvider + { + return $this->photoBuilderLlmModelProvider; + } + + public function setPhotoBuilderLlmModelProvider(?LlmModelProvider $photoBuilderLlmModelProvider): void + { + $this->photoBuilderLlmModelProvider = $photoBuilderLlmModelProvider; + } + + #[ORM\Column( + type: Types::STRING, + length: 1024, + nullable: true + )] + private ?string $photoBuilderLlmModelProviderApiKey = null; + + public function getPhotoBuilderLlmModelProviderApiKey(): ?string + { + return $this->photoBuilderLlmModelProviderApiKey; + } - public function getLlmApiKey(): string + public function setPhotoBuilderLlmModelProviderApiKey(?string $photoBuilderLlmModelProviderApiKey): void { - return $this->llmApiKey; + $this->photoBuilderLlmModelProviderApiKey = $photoBuilderLlmModelProviderApiKey; } - public function setLlmApiKey(string $llmApiKey): void + /** + * Returns the effective PhotoBuilder provider (dedicated or content editing fallback). + */ + public function getEffectivePhotoBuilderLlmModelProvider(): LlmModelProvider + { + return $this->photoBuilderLlmModelProvider ?? $this->contentEditingLlmModelProvider; + } + + /** + * Returns the effective PhotoBuilder API key (dedicated or content editing fallback). + */ + public function getEffectivePhotoBuilderLlmModelProviderApiKey(): string { - $this->llmApiKey = $llmApiKey; + return $this->photoBuilderLlmModelProviderApiKey ?? $this->contentEditingLlmModelProviderApiKey; } #[ORM\Column( diff --git a/src/ProjectMgmt/Domain/Service/ProjectService.php b/src/ProjectMgmt/Domain/Service/ProjectService.php index 9ea1b20..241d631 100644 --- a/src/ProjectMgmt/Domain/Service/ProjectService.php +++ b/src/ProjectMgmt/Domain/Service/ProjectService.php @@ -24,33 +24,35 @@ public function __construct( * @param list|null $remoteContentAssetsManifestUrls */ public function create( - string $organizationId, - string $name, - string $gitUrl, - string $githubToken, - LlmModelProvider $llmModelProvider, - string $llmApiKey, - ProjectType $projectType = ProjectType::DEFAULT, - string $agentImage = Project::DEFAULT_AGENT_IMAGE, - ?string $agentBackgroundInstructions = null, - ?string $agentStepInstructions = null, - ?string $agentOutputInstructions = null, - ?array $remoteContentAssetsManifestUrls = null, - ?string $s3BucketName = null, - ?string $s3Region = null, - ?string $s3AccessKeyId = null, - ?string $s3SecretAccessKey = null, - ?string $s3IamRoleArn = null, - ?string $s3KeyPrefix = null, - bool $keysVisible = true + string $organizationId, + string $name, + string $gitUrl, + string $githubToken, + LlmModelProvider $contentEditingLlmModelProvider, + string $contentEditingLlmModelProviderApiKey, + ProjectType $projectType = ProjectType::DEFAULT, + string $agentImage = Project::DEFAULT_AGENT_IMAGE, + ?string $agentBackgroundInstructions = null, + ?string $agentStepInstructions = null, + ?string $agentOutputInstructions = null, + ?array $remoteContentAssetsManifestUrls = null, + ?string $s3BucketName = null, + ?string $s3Region = null, + ?string $s3AccessKeyId = null, + ?string $s3SecretAccessKey = null, + ?string $s3IamRoleArn = null, + ?string $s3KeyPrefix = null, + bool $keysVisible = true, + ?LlmModelProvider $photoBuilderLlmModelProvider = null, + ?string $photoBuilderLlmModelProviderApiKey = null, ): Project { $project = new Project( $organizationId, $name, $gitUrl, $githubToken, - $llmModelProvider, - $llmApiKey, + $contentEditingLlmModelProvider, + $contentEditingLlmModelProviderApiKey, $projectType, $agentImage, $agentBackgroundInstructions, @@ -60,6 +62,8 @@ public function create( ); $project->setKeysVisible($keysVisible); + $project->setPhotoBuilderLlmModelProvider($photoBuilderLlmModelProvider); + $project->setPhotoBuilderLlmModelProviderApiKey($photoBuilderLlmModelProviderApiKey); // Set S3 configuration if provided $project->setS3BucketName($s3BucketName); @@ -79,30 +83,32 @@ public function create( * @param list|null $remoteContentAssetsManifestUrls */ public function update( - Project $project, - string $name, - string $gitUrl, - string $githubToken, - LlmModelProvider $llmModelProvider, - string $llmApiKey, - ProjectType $projectType = ProjectType::DEFAULT, - string $agentImage = Project::DEFAULT_AGENT_IMAGE, - ?string $agentBackgroundInstructions = null, - ?string $agentStepInstructions = null, - ?string $agentOutputInstructions = null, - ?array $remoteContentAssetsManifestUrls = null, - ?string $s3BucketName = null, - ?string $s3Region = null, - ?string $s3AccessKeyId = null, - ?string $s3SecretAccessKey = null, - ?string $s3IamRoleArn = null, - ?string $s3KeyPrefix = null + Project $project, + string $name, + string $gitUrl, + string $githubToken, + LlmModelProvider $contentEditingLlmModelProvider, + string $contentEditingLlmModelProviderApiKey, + ProjectType $projectType = ProjectType::DEFAULT, + string $agentImage = Project::DEFAULT_AGENT_IMAGE, + ?string $agentBackgroundInstructions = null, + ?string $agentStepInstructions = null, + ?string $agentOutputInstructions = null, + ?array $remoteContentAssetsManifestUrls = null, + ?string $s3BucketName = null, + ?string $s3Region = null, + ?string $s3AccessKeyId = null, + ?string $s3SecretAccessKey = null, + ?string $s3IamRoleArn = null, + ?string $s3KeyPrefix = null, + ?LlmModelProvider $photoBuilderLlmModelProvider = null, + ?string $photoBuilderLlmModelProviderApiKey = null, ): void { $project->setName($name); $project->setGitUrl($gitUrl); $project->setGithubToken($githubToken); - $project->setLlmModelProvider($llmModelProvider); - $project->setLlmApiKey($llmApiKey); + $project->setContentEditingLlmModelProvider($contentEditingLlmModelProvider); + $project->setContentEditingLlmModelProviderApiKey($contentEditingLlmModelProviderApiKey); $project->setProjectType($projectType); $project->setAgentImage($agentImage); @@ -119,6 +125,10 @@ public function update( $project->setRemoteContentAssetsManifestUrls($remoteContentAssetsManifestUrls); } + // PhotoBuilder LLM fields (null = use content editing settings) + $project->setPhotoBuilderLlmModelProvider($photoBuilderLlmModelProvider); + $project->setPhotoBuilderLlmModelProviderApiKey($photoBuilderLlmModelProviderApiKey); + // S3 fields are always updated (can be cleared by passing null) $project->setS3BucketName($s3BucketName); $project->setS3Region($s3Region); diff --git a/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php b/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php index c5e2283..107dd5f 100644 --- a/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php +++ b/src/ProjectMgmt/Facade/Dto/ProjectInfoDto.php @@ -13,26 +13,29 @@ * @param list $remoteContentAssetsManifestUrls */ public function __construct( - public string $id, - public string $name, - public string $gitUrl, - public string $githubToken, - public ProjectType $projectType, - public string $githubUrl, - public string $agentImage, - public LlmModelProvider $llmModelProvider, - public string $llmApiKey, - public string $agentBackgroundInstructions, - public string $agentStepInstructions, - public string $agentOutputInstructions, - public array $remoteContentAssetsManifestUrls = [], + public string $id, + public string $name, + public string $gitUrl, + public string $githubToken, + public ProjectType $projectType, + public string $githubUrl, + public string $agentImage, + public LlmModelProvider $contentEditingLlmModelProvider, + public string $contentEditingApiKey, + public string $agentBackgroundInstructions, + public string $agentStepInstructions, + public string $agentOutputInstructions, + public array $remoteContentAssetsManifestUrls = [], // S3 Upload Configuration (all optional) - public ?string $s3BucketName = null, - public ?string $s3Region = null, - public ?string $s3AccessKeyId = null, - public ?string $s3SecretAccessKey = null, - public ?string $s3IamRoleArn = null, - public ?string $s3KeyPrefix = null, + public ?string $s3BucketName = null, + public ?string $s3Region = null, + public ?string $s3AccessKeyId = null, + public ?string $s3SecretAccessKey = null, + public ?string $s3IamRoleArn = null, + public ?string $s3KeyPrefix = null, + // PhotoBuilder LLM Configuration (nullable = falls back to content editing) + public ?LlmModelProvider $photoBuilderLlmModelProvider = null, + public ?string $photoBuilderApiKey = null, ) { } @@ -46,4 +49,20 @@ public function hasS3UploadConfigured(): bool && $this->s3AccessKeyId !== null && $this->s3SecretAccessKey !== null; } + + /** + * Returns the effective PhotoBuilder provider (dedicated or content editing fallback). + */ + public function getEffectivePhotoBuilderLlmModelProvider(): LlmModelProvider + { + return $this->photoBuilderLlmModelProvider ?? $this->contentEditingLlmModelProvider; + } + + /** + * Returns the effective PhotoBuilder API key (dedicated or content editing fallback). + */ + public function getEffectivePhotoBuilderApiKey(): string + { + return $this->photoBuilderApiKey ?? $this->contentEditingApiKey; + } } diff --git a/src/ProjectMgmt/Facade/ProjectMgmtFacade.php b/src/ProjectMgmt/Facade/ProjectMgmtFacade.php index 908d1ca..da8051f 100644 --- a/src/ProjectMgmt/Facade/ProjectMgmtFacade.php +++ b/src/ProjectMgmt/Facade/ProjectMgmtFacade.php @@ -35,9 +35,9 @@ public function __construct( public function createProjectFromPrefab(string $organizationId, PrefabDto $prefab): string { - $llmModelProvider = LlmModelProvider::tryFrom($prefab->llmModelProvider); - if ($llmModelProvider === null) { - throw new RuntimeException('Invalid prefab llm_model_provider: ' . $prefab->llmModelProvider); + $contentEditingProvider = LlmModelProvider::tryFrom($prefab->contentEditingLlmModelProvider); + if ($contentEditingProvider === null) { + throw new RuntimeException('Invalid prefab content_editing_llm_model_provider: ' . $prefab->contentEditingLlmModelProvider); } $project = $this->projectService->create( @@ -45,8 +45,8 @@ public function createProjectFromPrefab(string $organizationId, PrefabDto $prefa $prefab->name, $prefab->projectLink, $prefab->githubAccessKey, - $llmModelProvider, - $prefab->llmApiKey, + $contentEditingProvider, + $prefab->contentEditingLlmApiKey, ProjectType::DEFAULT, Project::DEFAULT_AGENT_IMAGE, null, @@ -59,7 +59,9 @@ public function createProjectFromPrefab(string $organizationId, PrefabDto $prefa null, null, null, - $prefab->keysVisible + $prefab->keysVisible, + null, // photoBuilderLlmModelProvider: prefabs always use content editing settings + null, // photoBuilderLlmModelProviderApiKey ); $projectId = $project->getId(); @@ -132,7 +134,7 @@ public function getExistingLlmApiKeys(string $organizationId): array if (!$project->isKeysVisible()) { continue; } - $apiKey = $project->getLlmApiKey(); + $apiKey = $project->getContentEditingLlmModelProviderApiKey(); if ($apiKey === '') { continue; } @@ -169,8 +171,8 @@ private function toDto(Project $project): ProjectInfoDto $project->getProjectType(), $githubUrl, $project->getAgentImage(), - $project->getLlmModelProvider(), - $project->getLlmApiKey(), + $project->getContentEditingLlmModelProvider(), + $project->getContentEditingLlmModelProviderApiKey(), $project->getAgentBackgroundInstructions(), $project->getAgentStepInstructions(), $project->getAgentOutputInstructions(), @@ -181,6 +183,8 @@ private function toDto(Project $project): ProjectInfoDto $project->getS3SecretAccessKey(), $project->getS3IamRoleArn(), $project->getS3KeyPrefix(), + $project->getPhotoBuilderLlmModelProvider(), + $project->getPhotoBuilderLlmModelProviderApiKey(), ); } diff --git a/src/ProjectMgmt/Presentation/Controller/ProjectController.php b/src/ProjectMgmt/Presentation/Controller/ProjectController.php index ed27a7d..0ad6e7d 100644 --- a/src/ProjectMgmt/Presentation/Controller/ProjectController.php +++ b/src/ProjectMgmt/Presentation/Controller/ProjectController.php @@ -155,12 +155,12 @@ public function create(Request $request): Response return $this->redirectToRoute('project_mgmt.presentation.new'); } - $name = $request->request->getString('name'); - $gitUrl = $request->request->getString('git_url'); - $githubToken = $request->request->getString('github_token'); - $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); - $llmApiKey = $request->request->getString('llm_api_key'); - $agentImage = $this->resolveAgentImage($request); + $name = $request->request->getString('name'); + $gitUrl = $request->request->getString('git_url'); + $githubToken = $request->request->getString('github_token'); + $contentEditingLlmModelProvider = LlmModelProvider::tryFrom($request->request->getString('content_editing_llm_model_provider')); + $contentEditingApiKey = $request->request->getString('content_editing_api_key'); + $agentImage = $this->resolveAgentImage($request); // Agent configuration (optional - uses template defaults if empty) $agentBackgroundInstructions = $this->nullIfEmpty($request->request->getString('agent_background_instructions')); @@ -168,13 +168,13 @@ public function create(Request $request): Response $agentOutputInstructions = $this->nullIfEmpty($request->request->getString('agent_output_instructions')); $remoteContentAssetsManifestUrls = $this->parseRemoteContentAssetsManifestUrls($request); - if ($name === '' || $gitUrl === '' || $githubToken === '' || $llmApiKey === '') { + if ($name === '' || $gitUrl === '' || $githubToken === '' || $contentEditingApiKey === '') { $this->addFlash('error', $this->translator->trans('flash.error.all_fields_required')); return $this->redirectToRoute('project_mgmt.presentation.new'); } - if ($llmModelProvider === null) { + if ($contentEditingLlmModelProvider === null) { $this->addFlash('error', $this->translator->trans('flash.error.select_llm_provider')); return $this->redirectToRoute('project_mgmt.presentation.new'); @@ -194,6 +194,9 @@ public function create(Request $request): Response return $this->redirectToRoute('account.presentation.dashboard'); } + // PhotoBuilder LLM configuration + [$photoBuilderProvider, $photoBuilderApiKey] = $this->resolvePhotoBuilderLlmSettings($request); + // S3 upload configuration (all optional) $s3BucketName = $this->nullIfEmpty($request->request->getString('s3_bucket_name')); $s3Region = $this->nullIfEmpty($request->request->getString('s3_region')); @@ -207,8 +210,8 @@ public function create(Request $request): Response $name, $gitUrl, $githubToken, - $llmModelProvider, - $llmApiKey, + $contentEditingLlmModelProvider, + $contentEditingApiKey, ProjectType::DEFAULT, $agentImage, $agentBackgroundInstructions, @@ -220,7 +223,10 @@ public function create(Request $request): Response $s3AccessKeyId, $s3SecretAccessKey, $s3IamRoleArn, - $s3KeyPrefix + $s3KeyPrefix, + true, + $photoBuilderProvider, + $photoBuilderApiKey, ); $this->addFlash('success', $this->translator->trans('flash.success.project_created')); @@ -251,28 +257,30 @@ public function edit(string $id): Response // Filter out the current project's key from the reuse list // Only show keys from the user's organization (security boundary) - $currentKey = $project->getLlmApiKey(); + $currentKey = $project->getContentEditingLlmModelProviderApiKey(); $existingLlmKeys = array_values(array_filter( $this->projectMgmtFacade->getExistingLlmApiKeys($organizationId), static fn (ExistingLlmApiKeyDto $key) => $key->apiKey !== $currentKey )); // When keys are not visible (e.g. prefab project), do not send real keys to the template - $keysVisible = $project->isKeysVisible(); - $displayGithubToken = $keysVisible ? $project->getGithubToken() : ''; - $displayLlmApiKey = $keysVisible ? $project->getLlmApiKey() : ''; + $keysVisible = $project->isKeysVisible(); + $displayGithubToken = $keysVisible ? $project->getGithubToken() : ''; + $displayContentEditingApiKey = $keysVisible ? $project->getContentEditingLlmModelProviderApiKey() : ''; + $displayPhotoBuilderApiKey = $keysVisible ? ($project->getPhotoBuilderLlmModelProviderApiKey() ?? '') : ''; // Get agent config template (used as fallback in template, but project values take precedence) $agentConfigTemplate = $this->projectMgmtFacade->getAgentConfigTemplate($project->getProjectType()); return $this->render('@project_mgmt.presentation/project_form.twig', [ - 'project' => $project, - 'llmProviders' => LlmModelProvider::cases(), - 'existingLlmKeys' => $existingLlmKeys, - 'agentConfigTemplate' => $agentConfigTemplate, - 'keysVisible' => $keysVisible, - 'displayGithubToken' => $displayGithubToken, - 'displayLlmApiKey' => $displayLlmApiKey, + 'project' => $project, + 'llmProviders' => LlmModelProvider::cases(), + 'existingLlmKeys' => $existingLlmKeys, + 'agentConfigTemplate' => $agentConfigTemplate, + 'keysVisible' => $keysVisible, + 'displayGithubToken' => $displayGithubToken, + 'displayContentEditingApiKey' => $displayContentEditingApiKey, + 'displayPhotoBuilderApiKey' => $displayPhotoBuilderApiKey, ]); } @@ -296,13 +304,13 @@ public function update(string $id, Request $request): Response return $this->redirectToRoute('project_mgmt.presentation.edit', ['id' => $id]); } - $name = $request->request->getString('name'); - $gitUrl = $request->request->getString('git_url'); - $keysVisible = $project->isKeysVisible(); - $githubToken = $keysVisible ? $request->request->getString('github_token') : $project->getGithubToken(); - $llmModelProvider = LlmModelProvider::tryFrom($request->request->getString('llm_model_provider')); - $llmApiKey = $keysVisible ? $request->request->getString('llm_api_key') : $project->getLlmApiKey(); - $agentImage = $this->resolveAgentImage($request); + $name = $request->request->getString('name'); + $gitUrl = $request->request->getString('git_url'); + $keysVisible = $project->isKeysVisible(); + $githubToken = $keysVisible ? $request->request->getString('github_token') : $project->getGithubToken(); + $contentEditingLlmModelProvider = LlmModelProvider::tryFrom($request->request->getString('content_editing_llm_model_provider')); + $contentEditingApiKey = $keysVisible ? $request->request->getString('content_editing_api_key') : $project->getContentEditingLlmModelProviderApiKey(); + $agentImage = $this->resolveAgentImage($request); // Agent configuration (null means keep existing values) $agentBackgroundInstructions = $this->nullIfEmpty($request->request->getString('agent_background_instructions')); @@ -311,13 +319,13 @@ public function update(string $id, Request $request): Response $remoteContentAssetsManifestUrls = $this->parseRemoteContentAssetsManifestUrls($request); $requireKeys = $keysVisible; - if ($name === '' || $gitUrl === '' || ($requireKeys && ($githubToken === '' || $llmApiKey === ''))) { + if ($name === '' || $gitUrl === '' || ($requireKeys && ($githubToken === '' || $contentEditingApiKey === ''))) { $this->addFlash('error', $this->translator->trans('flash.error.all_fields_required')); return $this->redirectToRoute('project_mgmt.presentation.edit', ['id' => $id]); } - if ($llmModelProvider === null) { + if ($contentEditingLlmModelProvider === null) { $this->addFlash('error', $this->translator->trans('flash.error.select_llm_provider')); return $this->redirectToRoute('project_mgmt.presentation.edit', ['id' => $id]); @@ -329,6 +337,11 @@ public function update(string $id, Request $request): Response return $this->redirectToRoute('project_mgmt.presentation.edit', ['id' => $id]); } + // PhotoBuilder LLM configuration + [$photoBuilderProvider, $photoBuilderApiKey] = $keysVisible + ? $this->resolvePhotoBuilderLlmSettings($request) + : [$project->getPhotoBuilderLlmModelProvider(), $project->getPhotoBuilderLlmModelProviderApiKey()]; + // S3 upload configuration (all optional) $s3BucketName = $this->nullIfEmpty($request->request->getString('s3_bucket_name')); $s3Region = $this->nullIfEmpty($request->request->getString('s3_region')); @@ -342,8 +355,8 @@ public function update(string $id, Request $request): Response $name, $gitUrl, $githubToken, - $llmModelProvider, - $llmApiKey, + $contentEditingLlmModelProvider, + $contentEditingApiKey, ProjectType::DEFAULT, $agentImage, $agentBackgroundInstructions, @@ -355,7 +368,9 @@ public function update(string $id, Request $request): Response $s3AccessKeyId, $s3SecretAccessKey, $s3IamRoleArn, - $s3KeyPrefix + $s3KeyPrefix, + $photoBuilderProvider, + $photoBuilderApiKey, ); $this->addFlash('success', $this->translator->trans('flash.success.project_updated')); @@ -586,6 +601,33 @@ public function verifyS3Credentials(Request $request): JsonResponse return new JsonResponse(['valid' => $valid]); } + /** + * Resolve PhotoBuilder LLM settings from the form. + * Returns [null, null] for Option A (use content editing settings), + * or [LlmModelProvider, string] for Option B (dedicated settings). + * + * @return array{0: ?LlmModelProvider, 1: ?string} + */ + private function resolvePhotoBuilderLlmSettings(Request $request): array + { + $mode = $request->request->getString('photo_builder_llm_mode'); + + if ($mode !== 'dedicated') { + // Option A: reuse content editing settings + return [null, null]; + } + + $providerValue = $request->request->getString('photo_builder_llm_model_provider'); + $apiKey = $request->request->getString('photo_builder_api_key'); + $provider = LlmModelProvider::tryFrom($providerValue); + + if ($provider === null || $apiKey === '') { + return [null, null]; + } + + return [$provider, $apiKey]; + } + /** * Resolve the agent image from the request. * If "custom" is selected, use the custom_agent_image field. diff --git a/src/ProjectMgmt/Presentation/Resources/assets/controllers/llm_key_verification_controller.ts b/src/ProjectMgmt/Presentation/Resources/assets/controllers/llm_key_verification_controller.ts index 34784f0..3569895 100644 --- a/src/ProjectMgmt/Presentation/Resources/assets/controllers/llm_key_verification_controller.ts +++ b/src/ProjectMgmt/Presentation/Resources/assets/controllers/llm_key_verification_controller.ts @@ -71,10 +71,14 @@ export default class extends Controller { private async performVerification(apiKey: string): Promise { this.isVerifying = true; - // Get the selected provider - const providerInput = document.querySelector( - 'input[name="llm_model_provider"]:checked', - ) as HTMLInputElement | null; + // Get the selected provider: look in the closest fieldset/form ancestor first + // (covers the PhotoBuilder dedicated settings panel where radios are siblings), + // then fall back to the content editing provider radio. + const scope = this.element.closest("fieldset") ?? this.element.closest("form") ?? document; + const providerInput = (scope.querySelector('input[name$="_llm_model_provider"]:checked') ?? + document.querySelector( + 'input[name="content_editing_llm_model_provider"]:checked', + )) as HTMLInputElement | null; const provider = providerInput?.value || "openai"; // Show spinner, hide others diff --git a/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig b/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig index 6c0a093..f9314ab 100644 --- a/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig +++ b/src/ProjectMgmt/Presentation/Resources/templates/project_form.twig @@ -64,34 +64,36 @@

    {{ 'project.form.github_access_key_help'|trans }}

    - {# LLM Model Provider #} + {# Content Editing LLM Model Provider #}
    - {{ 'project.form.llm_provider'|trans }} + {{ 'project.form.content_editing_llm_provider'|trans }}
    {% for provider in llmProviders %} - + {% if provider.value == 'openai' %} + + {% endif %} {% endfor %}
    - {# LLM API Key with verification #} + {# Content Editing LLM API Key with verification #}
    - +
    -

    {{ 'project.form.llm_api_key_help'|trans }}

    +

    {{ 'project.form.content_editing_api_key_help'|trans }}

    {# Reuse existing keys #} {% if existingLlmKeys|length > 0 %} @@ -145,6 +147,138 @@ {% endif %}
    + {# PhotoBuilder LLM Settings #} + {% if keysVisible is not defined or keysVisible %} + {% set currentPhotoBuilderMode = (project and project.photoBuilderLlmModelProvider is not null) ? 'dedicated' : 'reuse' %} +
    + {{ 'project.form.photo_builder_llm_settings'|trans }} +

    {{ 'project.form.photo_builder_llm_settings_help'|trans }}

    + + {# Option A: Reuse content editing settings #} + + + {# Option B: Dedicated settings #} + + + {# Dedicated settings panel (shown only when Option B is selected) #} +
    + {# Provider selection #} +
    + {{ 'project.form.photo_builder_provider'|trans }} + {% for provider in llmProviders %} + {% if provider.supportsPhotoBuilder %} + + {% endif %} + {% endfor %} +
    + + {# API Key with verification #} +
    + + + + {# Verification status #} +
    + + + +
    + + {# Reuse existing keys for PhotoBuilder #} + {% if existingLlmKeys|length > 0 %} +
    +

    {{ 'project.form.reuse_existing_key'|trans }}

    +
    + {% for key in existingLlmKeys %} + + {% endfor %} +
    +
    + {% endif %} +
    +
    + + +
    + {% endif %} + {# GitHub Access Key Helper - shown after base required fields #}

    diff --git a/src/RemoteContentAssets/Facade/RemoteContentAssetsFacade.php b/src/RemoteContentAssets/Facade/RemoteContentAssetsFacade.php index ec77896..c0ee2b4 100644 --- a/src/RemoteContentAssets/Facade/RemoteContentAssetsFacade.php +++ b/src/RemoteContentAssets/Facade/RemoteContentAssetsFacade.php @@ -10,6 +10,12 @@ use App\RemoteContentAssets\Infrastructure\RemoteManifestValidatorInterface; use App\RemoteContentAssets\Infrastructure\S3AssetUploaderInterface; +use function array_key_exists; +use function basename; +use function parse_url; + +use const PHP_URL_PATH; + final class RemoteContentAssetsFacade implements RemoteContentAssetsFacadeInterface { public function __construct( @@ -40,6 +46,38 @@ public function fetchAndMergeAssetUrls(array $manifestUrls): array return $this->manifestFetcher->fetchAndMergeAssetUrls($manifestUrls); } + /** + * @param list $manifestUrls + * @param list $fileNames + * + * @return list + */ + public function findAvailableFileNames(array $manifestUrls, array $fileNames): array + { + if ($fileNames === [] || $manifestUrls === []) { + return []; + } + + $allUrls = $this->manifestFetcher->fetchAndMergeAssetUrls($manifestUrls); + + $availableBasenames = []; + foreach ($allUrls as $url) { + $path = parse_url($url, PHP_URL_PATH); + if ($path !== null && $path !== false) { + $availableBasenames[basename($path)] = true; + } + } + + $found = []; + foreach ($fileNames as $fileName) { + if (array_key_exists($fileName, $availableBasenames)) { + $found[] = $fileName; + } + } + + return $found; + } + public function verifyS3Credentials( string $bucketName, string $region, diff --git a/src/RemoteContentAssets/Facade/RemoteContentAssetsFacadeInterface.php b/src/RemoteContentAssets/Facade/RemoteContentAssetsFacadeInterface.php index 6ccb567..5b9ac96 100644 --- a/src/RemoteContentAssets/Facade/RemoteContentAssetsFacadeInterface.php +++ b/src/RemoteContentAssets/Facade/RemoteContentAssetsFacadeInterface.php @@ -61,6 +61,17 @@ public function verifyS3Credentials( ?string $iamRoleArn ): bool; + /** + * Check which of the given filenames are present in any of the manifest URLs. + * Matches by basename only (folder/path prefix is irrelevant). + * + * @param list $manifestUrls + * @param list $fileNames Basenames to look for (e.g. "00fa0883_office.png") + * + * @return list The subset of $fileNames that were found + */ + public function findAvailableFileNames(array $manifestUrls, array $fileNames): array; + /** * Upload an asset to S3. * diff --git a/src/RemoteContentAssets/Presentation/Resources/templates/_remote_asset_browser_sidebar.html.twig b/src/RemoteContentAssets/Presentation/Resources/templates/_remote_asset_browser_sidebar.html.twig new file mode 100644 index 0000000..e2d9da4 --- /dev/null +++ b/src/RemoteContentAssets/Presentation/Resources/templates/_remote_asset_browser_sidebar.html.twig @@ -0,0 +1,82 @@ +{# Remote Assets sidebar widget. Include when hasRemoteAssets is true. + Required: project, workspaceId + Optional: windowSize (default 20), parentStimulusActions (array of { controller, action, event } for stimulus_action) #} +{% set _windowSize = windowSize|default(20) %} +{% set _parentStimulusActions = parentStimulusActions|default([]) %} +

    + {# On narrow screens: border-top separator. On wide screens: left border #} +
    +
    +

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

    + +
    + + {# Upload dropzone - only show if S3 is configured #} + {% if project.hasS3UploadConfigured() %} +
    + + + +

    {{ 'remote_content_assets.browser_upload_dropzone'|trans }}

    +

    {{ 'remote_content_assets.browser_upload_hint'|trans }}

    +
    + {# Upload progress indicator #} + + {# Upload success message #} + + {# Upload error message #} + + {% endif %} + +
    + +
    +
    + {{ 'common.loading'|trans }} +
    + +
    +
    +
    diff --git a/tests/Unit/LlmContentEditor/LlmModelProviderTest.php b/tests/Unit/LlmContentEditor/LlmModelProviderTest.php index d974965..a5b664d 100644 --- a/tests/Unit/LlmContentEditor/LlmModelProviderTest.php +++ b/tests/Unit/LlmContentEditor/LlmModelProviderTest.php @@ -10,14 +10,22 @@ final class LlmModelProviderTest extends TestCase { - public function testOpenAiProviderSupportedModelsContainsGpt52(): void + public function testOpenAiProviderSupportedTextModelsContainsGpt52(): void { - $models = LlmModelProvider::OpenAI->supportedModels(); + $models = LlmModelProvider::OpenAI->supportedTextModels(); self::assertNotEmpty($models); self::assertContains(LlmModelName::Gpt52, $models); } + public function testGoogleProviderSupportedTextModelsContainsGemini(): void + { + $models = LlmModelProvider::Google->supportedTextModels(); + + self::assertNotEmpty($models); + self::assertContains(LlmModelName::Gemini3ProPreview, $models); + } + public function testAllProviderCasesHaveDisplayName(): void { foreach (LlmModelProvider::cases() as $provider) { @@ -25,10 +33,37 @@ public function testAllProviderCasesHaveDisplayName(): void } } - public function testAllProviderCasesHaveAtLeastOneSupportedModel(): void + public function testAllProviderCasesHaveAtLeastOneSupportedTextModel(): void + { + foreach (LlmModelProvider::cases() as $provider) { + self::assertNotEmpty($provider->supportedTextModels()); + } + } + + public function testAllProviderCasesHaveImageGenerationModel(): void + { + foreach (LlmModelProvider::cases() as $provider) { + self::assertTrue($provider->imageGenerationModel()->isImageGenerationModel()); + } + } + + public function testAllProviderCasesHaveImagePromptGenerationModel(): void + { + foreach (LlmModelProvider::cases() as $provider) { + self::assertFalse($provider->imagePromptGenerationModel()->isImageGenerationModel()); + } + } + + public function testOnlyOpenAiSupportsContentEditing(): void + { + self::assertTrue(LlmModelProvider::OpenAI->supportsContentEditing()); + self::assertFalse(LlmModelProvider::Google->supportsContentEditing()); + } + + public function testAllProvidersSupportPhotoBuilder(): void { foreach (LlmModelProvider::cases() as $provider) { - self::assertNotEmpty($provider->supportedModels()); + self::assertTrue($provider->supportsPhotoBuilder()); } } } diff --git a/tests/Unit/PhotoBuilder/GeminiImageGeneratorTest.php b/tests/Unit/PhotoBuilder/GeminiImageGeneratorTest.php new file mode 100644 index 0000000..75f40ac --- /dev/null +++ b/tests/Unit/PhotoBuilder/GeminiImageGeneratorTest.php @@ -0,0 +1,180 @@ + [ + [ + 'content' => [ + 'parts' => [ + ['inlineData' => ['data' => $fakeB64, 'mimeType' => 'image/png']], + ], + ], + ], + ], + ]); + + $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') + ->willReturn($response); + + $generator = new GeminiImageGenerator($httpClient); + $result = $generator->generateImage('A beautiful sunset', 'test-key'); + + self::assertSame($fakeImageData, $result); + } + + public function testPassesImageSizeInGenerationConfig(): void + { + $fakeImageData = 'fake-png-image-bytes'; + $fakeB64 = base64_encode($fakeImageData); + $responsePayload = json_encode([ + 'candidates' => [ + [ + 'content' => [ + 'parts' => [ + ['inlineData' => ['data' => $fakeB64, 'mimeType' => 'image/png']], + ], + ], + ], + ], + ]); + + $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', + self::stringContains('gemini-3-pro-image-preview'), + self::callback(static function (mixed $options): bool { + if (!is_array($options)) { + return false; + } + + /** @var array{json: array{generationConfig: array{imageConfig?: array{imageSize?: string}}}} $opts */ + $opts = $options; + $generationConfig = $opts['json']['generationConfig']; + + return array_key_exists('imageConfig', $generationConfig) + && ($generationConfig['imageConfig']['imageSize'] ?? null) === '1K'; + }) + ) + ->willReturn($response); + + $generator = new GeminiImageGenerator($httpClient); + $result = $generator->generateImage('A beautiful sunset', 'test-key', '1K'); + + self::assertSame($fakeImageData, $result); + } + + public function testOmitsImageConfigWhenImageSizeIsNull(): void + { + $fakeImageData = 'fake-png-image-bytes'; + $fakeB64 = base64_encode($fakeImageData); + $responsePayload = json_encode([ + 'candidates' => [ + [ + 'content' => [ + 'parts' => [ + ['inlineData' => ['data' => $fakeB64, 'mimeType' => 'image/png']], + ], + ], + ], + ], + ]); + + $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', + self::stringContains('gemini-3-pro-image-preview'), + self::callback(static function (mixed $options): bool { + if (!is_array($options)) { + return false; + } + + /** @var array{json: array{generationConfig: array}} $opts */ + $opts = $options; + $generationConfig = $opts['json']['generationConfig']; + + return !array_key_exists('imageConfig', $generationConfig); + }) + ) + ->willReturn($response); + + $generator = new GeminiImageGenerator($httpClient); + $generator->generateImage('A sunset', 'test-key', null); + } + + 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 GeminiImageGenerator($httpClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('status 429'); + $generator->generateImage('prompt', 'key'); + } + + public function testThrowsExceptionOnMissingImageData(): void + { + $responsePayload = json_encode([ + 'candidates' => [ + [ + 'content' => [ + 'parts' => [ + ['text' => 'No image here'], + ], + ], + ], + ], + ]); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getContent')->willReturn($responsePayload); + + $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient->method('request')->willReturn($response); + + $generator = new GeminiImageGenerator($httpClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('no image data'); + $generator->generateImage('prompt', 'key'); + } +} diff --git a/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php b/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php new file mode 100644 index 0000000..cccdbaf --- /dev/null +++ b/tests/Unit/PhotoBuilder/GeneratedImageStorageTest.php @@ -0,0 +1,148 @@ +isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($dir); +} + +/** + * @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 { + [$baseDir, $storage] = createStorageFixture(); + + try { + $imageData = 'fake-png-data'; + $storagePath = $storage->save('session-123', 0, $imageData); + + expect($storagePath)->toBe('session-123/0.png'); + + $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 { + [$baseDir, $storage] = createStorageFixture(); + + try { + $storage->save('new-session', 3, 'data'); + + $dir = $baseDir . '/new-session'; + expect(is_dir($dir))->toBeTrue(); + } finally { + cleanupTestDir($baseDir); + } + }); + + it('handles multiple positions in same session', function (): void { + [$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 { + [$baseDir, $storage] = createStorageFixture(); + + try { + $storage->save('session-123', 0, '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 { + [$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 { + [$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 { + [$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 { + [$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 new file mode 100644 index 0000000..c1caad1 --- /dev/null +++ b/tests/Unit/PhotoBuilder/OpenAiImageGeneratorTest.php @@ -0,0 +1,87 @@ + [['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['output_format'] === 'png' + && $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/PatchedGeminiTest.php b/tests/Unit/PhotoBuilder/PatchedGeminiTest.php new file mode 100644 index 0000000..a3fdf34 --- /dev/null +++ b/tests/Unit/PhotoBuilder/PatchedGeminiTest.php @@ -0,0 +1,197 @@ +buildGeminiResponse([ + ['functionCall' => ['name' => 'my_tool', 'args' => ['input' => 'hello']], 'thoughtSignature' => 'sig-abc'], + ]); + + $provider = $this->createPatchedGemini($responseBody); + + $result = $provider->chat([new UserMessage('test')]); + + self::assertInstanceOf(ToolCallMessage::class, $result); + self::assertCount(1, $result->getTools()); + self::assertSame('my_tool', $result->getTools()[0]->getName()); + self::assertSame('sig-abc', $result->getMetadata('thoughtSignature')); + } + + public function testDetectsFunctionCallAfterTextPart(): void + { + $responseBody = $this->buildGeminiResponse([ + ['text' => 'Let me help you with that.'], + ['functionCall' => ['name' => 'my_tool', 'args' => ['input' => 'data']], 'thoughtSignature' => 'sig-def'], + ]); + + $provider = $this->createPatchedGemini($responseBody); + + $result = $provider->chat([new UserMessage('test')]); + + self::assertInstanceOf(ToolCallMessage::class, $result); + self::assertCount(1, $result->getTools()); + self::assertSame('my_tool', $result->getTools()[0]->getName()); + } + + public function testDetectsMultipleFunctionCallsAfterTextPart(): void + { + $responseBody = $this->buildGeminiResponse([ + ['text' => 'I will generate the prompts now.'], + ['functionCall' => ['name' => 'tool_a', 'args' => ['x' => '1']], 'thoughtSignature' => 'sig-multi'], + ['functionCall' => ['name' => 'tool_b', 'args' => ['x' => '2']]], + ]); + + $provider = $this->createPatchedGeminiWithTools( + $responseBody, + [ + $this->makeTool('tool_a'), + $this->makeTool('tool_b'), + ], + ); + + $result = $provider->chat([new UserMessage('test')]); + + self::assertInstanceOf(ToolCallMessage::class, $result); + self::assertCount(2, $result->getTools()); + } + + public function testReturnsAssistantMessageWhenNoFunctionCall(): void + { + $responseBody = $this->buildGeminiResponse([ + ['text' => 'Here is a plain text response.'], + ]); + + $provider = $this->createPatchedGemini($responseBody); + + $result = $provider->chat([new UserMessage('test')]); + + self::assertInstanceOf(AssistantMessage::class, $result); + self::assertSame('Here is a plain text response.', $result->getContent()); + } + + public function testReturnsAssistantMessageForMultipleTextParts(): void + { + $responseBody = $this->buildGeminiResponse([ + ['text' => 'First part of the answer.'], + ['text' => 'Second part of the answer.'], + ]); + + $provider = $this->createPatchedGemini($responseBody); + + $result = $provider->chat([new UserMessage('test')]); + + self::assertInstanceOf(AssistantMessage::class, $result); + self::assertSame('First part of the answer.', $result->getContent()); + } + + public function testAttachesUsageMetadata(): void + { + $responseBody = json_encode([ + 'candidates' => [ + [ + 'content' => [ + 'parts' => [['text' => 'response']], + ], + 'finishReason' => 'STOP', + ], + ], + 'usageMetadata' => [ + 'promptTokenCount' => 42, + 'candidatesTokenCount' => 13, + ], + ], JSON_THROW_ON_ERROR); + + $provider = $this->createPatchedGemini($responseBody); + + $result = $provider->chat([new UserMessage('test')]); + + $usage = $result->getUsage(); + self::assertNotNull($usage); + self::assertSame(42, $usage->inputTokens); + self::assertSame(13, $usage->outputTokens); + } + + /** + * @param array> $parts + */ + private function buildGeminiResponse(array $parts): string + { + return json_encode([ + 'candidates' => [ + [ + 'content' => [ + 'parts' => $parts, + ], + 'finishReason' => 'STOP', + ], + ], + 'usageMetadata' => [ + 'promptTokenCount' => 100, + 'candidatesTokenCount' => 50, + ], + ], JSON_THROW_ON_ERROR); + } + + private function createPatchedGemini(string $responseBody): PatchedGemini + { + return $this->createPatchedGeminiWithTools( + $responseBody, + [$this->makeTool('my_tool')], + ); + } + + /** + * @param list $tools + */ + private function createPatchedGeminiWithTools(string $responseBody, array $tools): PatchedGemini + { + $mock = new MockHandler([new Response(200, [], $responseBody)]); + $handlerStack = HandlerStack::create($mock); + $httpOptions = new HttpClientOptions(null, null, null, $handlerStack); + + $provider = new PatchedGemini( + 'fake-api-key', + 'gemini-3-pro-preview', + [], + $httpOptions, + ); + + $provider->setTools($tools); + + return $provider; + } + + private function makeTool(string $name): Tool + { + /** @var Tool $tool */ + $tool = Tool::make($name, "A test tool called {$name}.") + ->addProperty( + new ToolProperty('input', PropertyType::STRING, 'Test input', true), + ) + ->setCallable(static fn (string $input): string => "result: {$input}"); + + return $tool; + } +} diff --git a/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php b/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php new file mode 100644 index 0000000..a704bcf --- /dev/null +++ b/tests/Unit/PhotoBuilder/PhotoBuilderServiceTest.php @@ -0,0 +1,205 @@ +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()); + } + + public function testUpdateSessionStatusDoesNothingWhenNotAllImagesTerminal(): void + { + $em = $this->createMock(EntityManagerInterface::class); + $em->expects(self::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); + + self::assertSame(PhotoSessionStatus::GeneratingImages, $session->getStatus()); + } + + 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); + + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $service->updateSessionStatusFromImages($session); + + self::assertSame(PhotoSessionStatus::ImagesReady, $session->getStatus()); + } + + public function testUpdateSessionStatusSetsImagesReadyEvenWhenSomeFailed(): 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::Failed); + + $session->setStatus(PhotoSessionStatus::GeneratingImages); + $service->updateSessionStatusFromImages($session); + + self::assertSame(PhotoSessionStatus::ImagesReady, $session->getStatus()); + } +} diff --git a/tests/Unit/PhotoBuilder/PhotoImageTest.php b/tests/Unit/PhotoBuilder/PhotoImageTest.php new file mode 100644 index 0000000..31df683 --- /dev/null +++ b/tests/Unit/PhotoBuilder/PhotoImageTest.php @@ -0,0 +1,179 @@ +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(); + }); + + it('has null uploadedToMediaStoreAt initially', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + expect($image->getUploadedToMediaStoreAt())->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'); + }); + }); + + describe('uploadedToMediaStoreAt', function (): void { + it('can set and get uploadedToMediaStoreAt', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + $now = DateAndTimeService::getDateTimeImmutable(); + $image->setUploadedToMediaStoreAt($now); + expect($image->getUploadedToMediaStoreAt())->toBe($now); + }); + + it('can clear by setting null', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + $image->setUploadedToMediaStoreAt(DateAndTimeService::getDateTimeImmutable()); + $image->setUploadedToMediaStoreAt(null); + expect($image->getUploadedToMediaStoreAt())->toBeNull(); + }); + }); + + describe('uploadedFileName', function (): void { + it('can set and get uploadedFileName', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + $image->setUploadedFileName('00fa0883ee6db2e2_placeholder-image-1.png'); + expect($image->getUploadedFileName())->toBe('00fa0883ee6db2e2_placeholder-image-1.png'); + }); + + it('can clear by setting null', function (): void { + $session = new PhotoSession('ws-123', 'conv-456', 'index.html', 'prompt'); + $image = new PhotoImage($session, 0); + + $image->setUploadedFileName('00fa0883ee6db2e2_image.png'); + $image->setUploadedFileName(null); + expect($image->getUploadedFileName())->toBeNull(); + }); + }); +}); 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(); + }); + }); +}); diff --git a/tests/Unit/Prefab/PrefabLoaderTest.php b/tests/Unit/Prefab/PrefabLoaderTest.php index 08fcd91..f8065e1 100644 --- a/tests/Unit/Prefab/PrefabLoaderTest.php +++ b/tests/Unit/Prefab/PrefabLoaderTest.php @@ -51,8 +51,8 @@ public function testLoadReturnsPrefabsWhenValidYaml(): void self::assertSame('Test Prefab', $result[0]->name); self::assertSame('https://github.com/test/repo.git', $result[0]->projectLink); self::assertSame('ghp_test', $result[0]->githubAccessKey); - self::assertSame('openai', $result[0]->llmModelProvider); - self::assertSame('sk-test', $result[0]->llmApiKey); + self::assertSame('openai', $result[0]->contentEditingLlmModelProvider); + self::assertSame('sk-test', $result[0]->contentEditingLlmApiKey); self::assertFalse($result[0]->keysVisible); } finally { if (is_file($yamlPath)) { diff --git a/tests/Unit/ProjectMgmt/ProjectServiceTest.php b/tests/Unit/ProjectMgmt/ProjectServiceTest.php index c74ba00..ad9be4c 100644 --- a/tests/Unit/ProjectMgmt/ProjectServiceTest.php +++ b/tests/Unit/ProjectMgmt/ProjectServiceTest.php @@ -33,7 +33,7 @@ public function testCreateCreatesProjectWithGivenAttributes(): void self::assertSame('My Project', $project->getName()); self::assertSame('https://github.com/org/repo.git', $project->getGitUrl()); self::assertSame('github-token-123', $project->getGithubToken()); - self::assertSame('sk-test-key-123', $project->getLlmApiKey()); + self::assertSame('sk-test-key-123', $project->getContentEditingLlmModelProviderApiKey()); self::assertSame([], $project->getRemoteContentAssetsManifestUrls()); } @@ -83,7 +83,7 @@ public function testUpdateUpdatesAllProjectAttributes(): void self::assertSame('New Name', $project->getName()); self::assertSame('https://new.git', $project->getGitUrl()); self::assertSame('new-token', $project->getGithubToken()); - self::assertSame('sk-new-key', $project->getLlmApiKey()); + self::assertSame('sk-new-key', $project->getContentEditingLlmModelProviderApiKey()); } public function testUpdatePreservesCreatedAtTimestamp(): void diff --git a/tests/Unit/RemoteContentAssets/RemoteContentAssetsFacadeTest.php b/tests/Unit/RemoteContentAssets/RemoteContentAssetsFacadeTest.php index b418996..4eb5a6b 100644 --- a/tests/Unit/RemoteContentAssets/RemoteContentAssetsFacadeTest.php +++ b/tests/Unit/RemoteContentAssets/RemoteContentAssetsFacadeTest.php @@ -167,6 +167,96 @@ public function testUploadAssetPassesNullValuesCorrectly(): void self::assertSame($expectedUrl, $result); } + public function testFindAvailableFileNamesReturnsMatchingBasenames(): void + { + $manifestUrls = ['https://example.com/manifest.json']; + $manifestAssetUrls = [ + 'https://cdn.example.com/uploads/20260211/00fa0883_office-scene.png', + 'https://cdn.example.com/uploads/20260211/abc123_team-photo.png', + 'https://cdn.example.com/uploads/20260210/def456_landscape.jpg', + ]; + + $fetcher = $this->createMock(RemoteManifestFetcherInterface::class); + $fetcher->method('fetchAndMergeAssetUrls') + ->with($manifestUrls) + ->willReturn($manifestAssetUrls); + $facade = $this->createFacadeWithManifestFetcher($fetcher); + + $result = $facade->findAvailableFileNames($manifestUrls, [ + '00fa0883_office-scene.png', + 'abc123_team-photo.png', + 'missing_file.png', + ]); + + self::assertSame(['00fa0883_office-scene.png', 'abc123_team-photo.png'], $result); + } + + public function testFindAvailableFileNamesReturnsEmptyForNoMatches(): void + { + $manifestUrls = ['https://example.com/manifest.json']; + $manifestAssetUrls = [ + 'https://cdn.example.com/uploads/20260211/00fa0883_office-scene.png', + ]; + + $fetcher = $this->createMock(RemoteManifestFetcherInterface::class); + $fetcher->method('fetchAndMergeAssetUrls') + ->with($manifestUrls) + ->willReturn($manifestAssetUrls); + $facade = $this->createFacadeWithManifestFetcher($fetcher); + + $result = $facade->findAvailableFileNames($manifestUrls, ['not-in-manifest.png']); + + self::assertSame([], $result); + } + + public function testFindAvailableFileNamesReturnsEmptyForEmptyManifests(): void + { + $fetcher = $this->createMock(RemoteManifestFetcherInterface::class); + $fetcher->method('fetchAndMergeAssetUrls')->willReturn([]); + $facade = $this->createFacadeWithManifestFetcher($fetcher); + + $result = $facade->findAvailableFileNames([], ['some-file.png']); + + self::assertSame([], $result); + } + + public function testFindAvailableFileNamesReturnsEmptyForEmptyFileNames(): void + { + $fetcher = $this->createMock(RemoteManifestFetcherInterface::class); + $facade = $this->createFacadeWithManifestFetcher($fetcher); + + $result = $facade->findAvailableFileNames(['https://example.com/manifest.json'], []); + + self::assertSame([], $result); + } + + public function testFindAvailableFileNamesDoesNotFetchManifestsWhenFileNamesEmpty(): void + { + $fetcher = $this->createMock(RemoteManifestFetcherInterface::class); + $fetcher->expects($this->never())->method('fetchAndMergeAssetUrls'); + $facade = $this->createFacadeWithManifestFetcher($fetcher); + + $facade->findAvailableFileNames(['https://example.com/manifest.json'], []); + } + + public function testFindAvailableFileNamesMatchesByBasenameOnly(): void + { + $manifestUrls = ['https://example.com/manifest.json']; + $manifestAssetUrls = [ + 'https://cdn.example.com/deep/nested/path/target-file.png', + ]; + + $fetcher = $this->createMock(RemoteManifestFetcherInterface::class); + $fetcher->method('fetchAndMergeAssetUrls') + ->with($manifestUrls) + ->willReturn($manifestAssetUrls); + $facade = $this->createFacadeWithManifestFetcher($fetcher); + + $result = $facade->findAvailableFileNames($manifestUrls, ['target-file.png']); + + self::assertSame(['target-file.png'], $result); + } + private function createFacade(?RemoteImageInfoFetcherInterface $imageInfoFetcher = null): RemoteContentAssetsFacade { return new RemoteContentAssetsFacade( diff --git a/tests/frontend/unit/ChatBasedContentEditor/chat_based_content_editor_controller.test.ts b/tests/frontend/unit/ChatBasedContentEditor/chat_based_content_editor_controller.test.ts index 7e9da6a..cc8d490 100644 --- a/tests/frontend/unit/ChatBasedContentEditor/chat_based_content_editor_controller.test.ts +++ b/tests/frontend/unit/ChatBasedContentEditor/chat_based_content_editor_controller.test.ts @@ -425,3 +425,86 @@ describe("ChatBasedContentEditorController handleChunk progress chunks", () => { expect(lines[1].textContent).toBe("Editing dist/landing-1.html"); }); }); + +describe("ChatBasedContentEditorController prefillMessage", () => { + const createControllerWithPrefill = ( + prefillMessage: string, + ): { + controller: ChatBasedContentEditorController; + textarea: HTMLTextAreaElement; + } => { + const textarea = document.createElement("textarea"); + textarea.id = "instruction"; + document.body.appendChild(textarea); + + const controller = Object.create( + ChatBasedContentEditorController.prototype, + ) as ChatBasedContentEditorController; + const state = controller as unknown as { + hasInstructionTarget: boolean; + instructionTarget: HTMLTextAreaElement; + prefillMessageValue: string; + readOnlyValue: boolean; + contextUsageValue: undefined; + activeSessionValue: null; + turnsValue: []; + contextUsageUrlValue: string; + }; + state.hasInstructionTarget = true; + state.instructionTarget = textarea; + state.prefillMessageValue = prefillMessage; + state.readOnlyValue = false; + state.contextUsageValue = undefined; + state.activeSessionValue = null; + state.turnsValue = []; + state.contextUsageUrlValue = ""; + + // Stub methods that connect() calls + ( + controller as unknown as { + renderCompletedTurnsTechnicalContainers: () => void; + startContextUsagePolling: () => void; + } + ).renderCompletedTurnsTechnicalContainers = () => {}; + ( + controller as unknown as { + startContextUsagePolling: () => void; + } + ).startContextUsagePolling = () => {}; + + return { controller, textarea }; + }; + + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("should pre-fill instruction textarea with prefillMessage on connect", () => { + const { controller, textarea } = createControllerWithPrefill( + "Embed images sunset.jpg, office.jpg into page index.html", + ); + + controller.connect(); + + expect(textarea.value).toBe("Embed images sunset.jpg, office.jpg into page index.html"); + }); + + it("should not pre-fill when prefillMessage is empty", () => { + const { controller, textarea } = createControllerWithPrefill(""); + textarea.value = "Existing text"; + + controller.connect(); + + expect(textarea.value).toBe("Existing text"); + }); + + it("should focus the textarea when prefillMessage is set", () => { + const { controller, textarea } = createControllerWithPrefill("Embed images into page"); + + const focusSpy = vi.spyOn(textarea, "focus"); + + controller.connect(); + + expect(focusSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/frontend/unit/ChatBasedContentEditor/dist_files_controller.test.ts b/tests/frontend/unit/ChatBasedContentEditor/dist_files_controller.test.ts index 5b8a340..fd157d0 100644 --- a/tests/frontend/unit/ChatBasedContentEditor/dist_files_controller.test.ts +++ b/tests/frontend/unit/ChatBasedContentEditor/dist_files_controller.test.ts @@ -15,10 +15,18 @@ interface MockControllerState { pollUrlValue: string; pollIntervalValue: number; readOnlyValue: boolean; + photoBuilderUrlPatternValue: string; + photoBuilderLabelValue: string; + editHtmlLabelValue: string; + previewLabelValue: string; hasListTarget: boolean; listTarget: HTMLElement | null; hasContainerTarget: boolean; containerTarget: HTMLElement | null; + hasPhotoBuilderSectionTarget: boolean; + photoBuilderSectionTarget: HTMLElement | null; + hasPhotoBuilderLinksTarget: boolean; + photoBuilderLinksTarget: HTMLElement | null; pollingTimeoutId: ReturnType | null; lastFilesJson: string; isActive: boolean; @@ -35,12 +43,20 @@ const createController = ( state.pollUrlValue = "/workspace/test-id/dist-files"; state.pollIntervalValue = 3000; state.readOnlyValue = false; + state.photoBuilderUrlPatternValue = ""; + state.photoBuilderLabelValue = "Generate matching images"; + state.editHtmlLabelValue = "Edit HTML"; + state.previewLabelValue = "Preview"; // Default targets (not present) state.hasListTarget = false; state.listTarget = null; state.hasContainerTarget = false; state.containerTarget = null; + state.hasPhotoBuilderSectionTarget = false; + state.photoBuilderSectionTarget = null; + state.hasPhotoBuilderLinksTarget = false; + state.photoBuilderLinksTarget = null; // Private state state.pollingTimeoutId = null; @@ -60,17 +76,24 @@ const createController = ( return controller; }; -const createFullController = (): { +const createFullController = ( + overrides: Partial = {}, +): { controller: DistFilesController; elements: { list: HTMLUListElement; container: HTMLElement; + photoBuilderSection: HTMLElement; + photoBuilderLinks: HTMLElement; controllerElement: HTMLElement; }; } => { const list = document.createElement("ul"); const container = document.createElement("div"); container.classList.add("hidden"); + const photoBuilderSection = document.createElement("div"); + photoBuilderSection.classList.add("hidden"); + const photoBuilderLinks = document.createElement("div"); const controllerElement = document.createElement("div"); const controller = createController( @@ -79,6 +102,11 @@ const createFullController = (): { listTarget: list, hasContainerTarget: true, containerTarget: container, + hasPhotoBuilderSectionTarget: true, + photoBuilderSectionTarget: photoBuilderSection, + hasPhotoBuilderLinksTarget: true, + photoBuilderLinksTarget: photoBuilderLinks, + ...overrides, }, controllerElement, ); @@ -88,6 +116,8 @@ const createFullController = (): { elements: { list, container, + photoBuilderSection, + photoBuilderLinks, controllerElement, }, }; @@ -193,6 +223,26 @@ describe("DistFilesController", () => { controller.disconnect(); }); + it("should not include PhotoBuilder camera icon in file rows", async () => { + const { controller, elements } = createFullController({ + photoBuilderUrlPatternValue: "/photo-builder/ws-123?page=__PAGE_PATH__", + }); + const files: DistFile[] = [{ path: "index.html", url: "/workspaces/ws-123/dist/index.html" }]; + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 })); + + controller.connect(); + await runSinglePollCycle(); + + // File rows should only have edit + preview links, no camera icon + const fileRowLinks = elements.list.querySelectorAll("a"); + Array.from(fileRowLinks).forEach((link) => { + expect(link.getAttribute("title")).not.toBe("Generate matching images"); + }); + + controller.disconnect(); + }); + it("should display file path text", async () => { const { controller, elements } = createFullController(); const files: DistFile[] = [{ path: "pages/about.html", url: "/ws/dist/pages/about.html" }]; @@ -228,21 +278,7 @@ describe("DistFilesController", () => { describe("readOnly mode", () => { it("should show preview link when readOnly is true", async () => { - const list = document.createElement("ul"); - const container = document.createElement("div"); - container.classList.add("hidden"); - const controllerElement = document.createElement("div"); - - const controller = createController( - { - hasListTarget: true, - listTarget: list, - hasContainerTarget: true, - containerTarget: container, - readOnlyValue: true, - }, - controllerElement, - ); + const { controller, elements } = createFullController({ readOnlyValue: true }); const files: DistFile[] = [{ path: "index.html", url: "/workspaces/ws-123/dist/index.html" }]; vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 })); @@ -250,7 +286,7 @@ describe("DistFilesController", () => { controller.connect(); await runSinglePollCycle(); - const previewLink = list.querySelector('a[target="_blank"]'); + const previewLink = elements.list.querySelector('a[target="_blank"]'); expect(previewLink).not.toBeNull(); expect(previewLink?.getAttribute("href")).toBe("/workspaces/ws-123/dist/index.html"); @@ -273,21 +309,7 @@ describe("DistFilesController", () => { }); it("should not show edit links for any file when readOnly is true", async () => { - const list = document.createElement("ul"); - const container = document.createElement("div"); - container.classList.add("hidden"); - const controllerElement = document.createElement("div"); - - const controller = createController( - { - hasListTarget: true, - listTarget: list, - hasContainerTarget: true, - containerTarget: container, - readOnlyValue: true, - }, - controllerElement, - ); + const { controller, elements } = createFullController({ readOnlyValue: true }); const files: DistFile[] = [ { path: "index.html", url: "/workspaces/ws-123/dist/index.html" }, @@ -299,12 +321,12 @@ describe("DistFilesController", () => { controller.connect(); await runSinglePollCycle(); - const editLinks = list.querySelectorAll('a[title="Edit HTML"]'); + const editLinks = elements.list.querySelectorAll('a[title="Edit HTML"]'); expect(editLinks.length).toBe(0); - // But all preview links should be present - const previewLinks = list.querySelectorAll('a[target="_blank"]'); - expect(previewLinks.length).toBe(3); + // Preview button links + clickable filenames should all be present (2 per file) + const previewLinks = elements.list.querySelectorAll('a[target="_blank"]'); + expect(previewLinks.length).toBe(6); controller.disconnect(); }); @@ -405,6 +427,188 @@ describe("DistFilesController", () => { }); }); + describe("photoBuilder CTA", () => { + it("should show PhotoBuilder section and render links when photoBuilderUrlPattern is set", async () => { + const { controller, elements } = createFullController({ + photoBuilderUrlPatternValue: "/photo-builder/ws-123?page=__PAGE_PATH__&conversationId=conv-456", + }); + + const files: DistFile[] = [{ path: "index.html", url: "/workspaces/ws-123/dist/index.html" }]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 })); + + controller.connect(); + await runSinglePollCycle(); + + // Section should be visible + expect(elements.photoBuilderSection.classList.contains("hidden")).toBe(false); + + // Should have a link in the photoBuilderLinks container + const links = elements.photoBuilderLinks.querySelectorAll("a"); + expect(links.length).toBe(1); + expect(links[0].getAttribute("href")).toBe( + "/photo-builder/ws-123?page=" + encodeURIComponent("index.html") + "&conversationId=conv-456", + ); + expect(links[0].textContent).toContain("index.html"); + + controller.disconnect(); + }); + + it("should keep PhotoBuilder section hidden when photoBuilderUrlPattern is empty", async () => { + const { controller, elements } = createFullController(); + + const files: DistFile[] = [{ path: "index.html", url: "/workspaces/ws-123/dist/index.html" }]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 })); + + controller.connect(); + await runSinglePollCycle(); + + expect(elements.photoBuilderSection.classList.contains("hidden")).toBe(true); + expect(elements.photoBuilderLinks.children.length).toBe(0); + + controller.disconnect(); + }); + + it("should keep PhotoBuilder section hidden in readOnly mode even with URL pattern", async () => { + const { controller, elements } = createFullController({ + readOnlyValue: true, + photoBuilderUrlPatternValue: "/photo-builder/ws-123?page=__PAGE_PATH__", + }); + + const files: DistFile[] = [{ path: "index.html", url: "/workspaces/ws-123/dist/index.html" }]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 })); + + controller.connect(); + await runSinglePollCycle(); + + expect(elements.photoBuilderSection.classList.contains("hidden")).toBe(true); + expect(elements.photoBuilderLinks.children.length).toBe(0); + + controller.disconnect(); + }); + + it("should use custom label as link title", async () => { + const { controller, elements } = createFullController({ + photoBuilderUrlPatternValue: "/photo-builder/ws-123?page=__PAGE_PATH__", + photoBuilderLabelValue: "Custom label", + }); + + const files: DistFile[] = [{ path: "index.html", url: "/workspaces/ws-123/dist/index.html" }]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 })); + + controller.connect(); + await runSinglePollCycle(); + + const link = elements.photoBuilderLinks.querySelector('a[title="Custom label"]'); + expect(link).not.toBeNull(); + + controller.disconnect(); + }); + + it("should encode page path in PhotoBuilder URL", async () => { + const { controller, elements } = createFullController({ + photoBuilderUrlPatternValue: "/photo-builder/ws-1?page=__PAGE_PATH__", + }); + + const files: DistFile[] = [ + { path: "pages/about us.html", url: "/workspaces/ws-1/dist/pages/about us.html" }, + ]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 })); + + controller.connect(); + await runSinglePollCycle(); + + const link = elements.photoBuilderLinks.querySelector("a") as HTMLAnchorElement; + expect(link).not.toBeNull(); + expect(link.href).toContain(encodeURIComponent("pages/about us.html")); + + controller.disconnect(); + }); + + it("should render one link per page file in the PhotoBuilder section", async () => { + const { controller, elements } = createFullController({ + photoBuilderUrlPatternValue: "/photo-builder/ws-1?page=__PAGE_PATH__", + }); + + const files: DistFile[] = [ + { path: "index.html", url: "/workspaces/ws-1/dist/index.html" }, + { path: "about.html", url: "/workspaces/ws-1/dist/about.html" }, + { path: "contact.html", url: "/workspaces/ws-1/dist/contact.html" }, + ]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 })); + + controller.connect(); + await runSinglePollCycle(); + + const links = elements.photoBuilderLinks.querySelectorAll("a"); + expect(links.length).toBe(3); + expect(links[0].textContent).toContain("index.html"); + expect(links[1].textContent).toContain("about.html"); + expect(links[2].textContent).toContain("contact.html"); + + controller.disconnect(); + }); + + it("should hide PhotoBuilder section when files become empty", async () => { + const { controller, elements } = createFullController({ + photoBuilderUrlPatternValue: "/photo-builder/ws-1?page=__PAGE_PATH__", + }); + + // First poll with files + const fetchMock = vi.spyOn(globalThis, "fetch"); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ files: [{ path: "index.html", url: "/workspaces/ws-1/dist/index.html" }] }), + { status: 200 }, + ), + ); + + controller.connect(); + await runSinglePollCycle(); + + expect(elements.photoBuilderSection.classList.contains("hidden")).toBe(false); + + // Second poll with no files + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ files: [] }), { status: 200 })); + + await vi.advanceTimersByTimeAsync(3000); + + expect(elements.container.classList.contains("hidden")).toBe(true); + + controller.disconnect(); + }); + + it("should gracefully handle missing photoBuilder targets", async () => { + const list = document.createElement("ul"); + const container = document.createElement("div"); + container.classList.add("hidden"); + const controllerElement = document.createElement("div"); + + const controller = createController( + { + hasListTarget: true, + listTarget: list, + hasContainerTarget: true, + containerTarget: container, + hasPhotoBuilderSectionTarget: false, + hasPhotoBuilderLinksTarget: false, + photoBuilderUrlPatternValue: "/photo-builder/ws-1?page=__PAGE_PATH__", + }, + controllerElement, + ); + + const files: DistFile[] = [{ path: "index.html", url: "/workspaces/ws-1/dist/index.html" }]; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ files }), { status: 200 })); + + controller.connect(); + await runSinglePollCycle(); + + // Should not throw, file list still renders + expect(list.children.length).toBe(1); + + controller.disconnect(); + }); + }); + describe("edge cases", () => { it("should not render without list target", async () => { const controller = createController({ diff --git a/tests/frontend/unit/PhotoBuilder/photo_builder_controller.test.ts b/tests/frontend/unit/PhotoBuilder/photo_builder_controller.test.ts new file mode 100644 index 0000000..21b8c79 --- /dev/null +++ b/tests/frontend/unit/PhotoBuilder/photo_builder_controller.test.ts @@ -0,0 +1,1592 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import PhotoBuilderController from "../../../../src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_builder_controller.ts"; + +/** + * Unit tests for the PhotoBuilder Stimulus controller (orchestrator). + * Tests session creation, polling, state management, and event handling. + */ + +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; + uploadedToMediaStore?: boolean; + uploadedFileName?: string | null; +} + +interface MockControllerState { + createSessionUrlValue: string; + pollUrlPatternValue: string; + regeneratePromptsUrlPatternValue: string; + regenerateImageUrlPatternValue: string; + regenerateAllImagesUrlPatternValue: string; + updatePromptUrlPatternValue: string; + uploadToMediaStoreUrlPatternValue: string; + checkManifestAvailabilityUrlPatternValue: string; + csrfTokenValue: string; + workspaceIdValue: string; + pagePathValue: string; + conversationIdValue: string; + imageCountValue: number; + defaultUserPromptValue: string; + editorUrlValue: string; + hasRemoteAssetsValue: boolean; + supportsResolutionToggleValue: boolean; + embedPrefillMessageValue: string; + loadingOverlayTarget: HTMLElement; + mainContentTarget: HTMLElement; + userPromptTarget: HTMLTextAreaElement; + regeneratePromptsButtonTarget: HTMLButtonElement; + hasEmbedButtonTarget: boolean; + embedButtonTarget: HTMLButtonElement; + imageCardTargets: HTMLElement[]; + hasUploadingImagesOverlayTarget: boolean; + uploadingImagesOverlayTarget: HTMLElement; + hasWaitingForManifestOverlayTarget: boolean; + waitingForManifestOverlayTarget: HTMLElement; + hasResolutionToggleTarget: boolean; + resolutionToggleTarget: HTMLElement; + hasLoresButtonTarget: boolean; + loresButtonTarget: HTMLButtonElement; + hasHiresButtonTarget: boolean; + hiresButtonTarget: HTMLButtonElement; + sessionId: string | null; + pollingTimeoutId: ReturnType | null; + isActive: boolean; + anyGenerating: boolean; + lastImages: ImageData[]; + currentImageSize: string; + lastAppliedUserPrompt: string | null; + promptDebounceTimeouts: Record>; + lastPollStatus: string | null; +} + +const createController = ( + overrides: Partial = {}, +): { + controller: PhotoBuilderController; + elements: { + controllerElement: HTMLElement; + loadingOverlay: HTMLElement; + mainContent: HTMLElement; + userPrompt: HTMLTextAreaElement; + regeneratePromptsButton: HTMLButtonElement; + embedButton: HTMLButtonElement; + imageCards: HTMLElement[]; + }; +} => { + const controllerElement = document.createElement("div"); + const loadingOverlay = document.createElement("div"); + const mainContent = document.createElement("div"); + mainContent.classList.add("hidden"); + const userPrompt = document.createElement("textarea"); + const regeneratePromptsButton = document.createElement("button"); + const embedButton = document.createElement("button"); + + // Create image cards + const imageCards: HTMLElement[] = []; + for (let i = 0; i < 5; i++) { + const card = document.createElement("div"); + controllerElement.appendChild(card); + imageCards.push(card); + } + + controllerElement.appendChild(loadingOverlay); + controllerElement.appendChild(mainContent); + controllerElement.appendChild(userPrompt); + controllerElement.appendChild(regeneratePromptsButton); + controllerElement.appendChild(embedButton); + + const controller = Object.create(PhotoBuilderController.prototype) as PhotoBuilderController; + const state = controller as unknown as MockControllerState; + + state.createSessionUrlValue = "/api/photo-builder/sessions"; + state.pollUrlPatternValue = "/api/photo-builder/sessions/00000000-0000-0000-0000-000000000000"; + state.regeneratePromptsUrlPatternValue = + "/api/photo-builder/sessions/00000000-0000-0000-0000-000000000000/regenerate-prompts"; + state.regenerateImageUrlPatternValue = "/api/photo-builder/images/00000000-0000-0000-0000-111111111111/regenerate"; + state.regenerateAllImagesUrlPatternValue = + "/api/photo-builder/sessions/00000000-0000-0000-0000-000000000000/regenerate-all-images"; + state.updatePromptUrlPatternValue = "/api/photo-builder/images/00000000-0000-0000-0000-111111111111/update-prompt"; + state.uploadToMediaStoreUrlPatternValue = + "/api/photo-builder/images/00000000-0000-0000-0000-111111111111/upload-to-media-store"; + state.checkManifestAvailabilityUrlPatternValue = ""; + state.csrfTokenValue = "test-csrf-token"; + state.workspaceIdValue = "ws-123"; + state.pagePathValue = "index.html"; + state.conversationIdValue = "conv-456"; + state.imageCountValue = 5; + state.defaultUserPromptValue = "The generated images should convey professionalism."; + state.editorUrlValue = "/conversation/conv-456"; + state.hasRemoteAssetsValue = false; + state.supportsResolutionToggleValue = false; + state.embedPrefillMessageValue = "Embed images %fileNames% into page %pagePath%"; + + state.loadingOverlayTarget = loadingOverlay; + state.mainContentTarget = mainContent; + state.userPromptTarget = userPrompt; + state.regeneratePromptsButtonTarget = regeneratePromptsButton; + state.hasEmbedButtonTarget = true; + state.embedButtonTarget = embedButton; + state.imageCardTargets = imageCards; + state.hasUploadingImagesOverlayTarget = false; + state.uploadingImagesOverlayTarget = document.createElement("div"); + state.hasWaitingForManifestOverlayTarget = false; + state.waitingForManifestOverlayTarget = document.createElement("div"); + state.hasResolutionToggleTarget = false; + state.resolutionToggleTarget = document.createElement("div"); + state.hasLoresButtonTarget = false; + state.loresButtonTarget = document.createElement("button"); + state.hasHiresButtonTarget = false; + state.hiresButtonTarget = document.createElement("button"); + + state.sessionId = null; + state.pollingTimeoutId = null; + state.isActive = false; + state.anyGenerating = false; + state.lastImages = []; + state.currentImageSize = "1K"; + state.lastAppliedUserPrompt = null; + state.promptDebounceTimeouts = {}; + state.lastPollStatus = null; + + Object.assign(state, overrides); + + Object.defineProperty(controller, "element", { + get: () => controllerElement, + configurable: true, + }); + + return { + controller, + elements: { + controllerElement, + loadingOverlay, + mainContent, + userPrompt, + regeneratePromptsButton, + embedButton, + imageCards, + }, + }; +}; + +/** + * Helper to run a single async cycle (lets fetch promises resolve). + */ +async function flushPromises(): Promise { + await vi.advanceTimersByTimeAsync(0); +} + +describe("PhotoBuilderController", () => { + beforeEach(() => { + document.body.innerHTML = ""; + vi.useFakeTimers(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe("connect", () => { + it("should create a session on connect", async () => { + const { controller } = createController(); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ sessionId: "sess-1", status: "generating_prompts" }), { status: 200 }), + ); + + controller.connect(); + await flushPromises(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/photo-builder/sessions", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + "X-CSRF-Token": "test-csrf-token", + }), + }), + ); + + const state = controller as unknown as MockControllerState; + expect(state.sessionId).toBe("sess-1"); + + controller.disconnect(); + }); + + it("should send workspaceId, conversationId, pagePath, and userPrompt in session creation body", async () => { + const { controller } = createController(); + + let capturedBody = ""; + vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, options) => { + if (options && typeof options === "object" && "body" in options) { + capturedBody = options.body as string; + } + return new Response(JSON.stringify({ sessionId: "sess-1", status: "generating_prompts" }), { + status: 200, + }); + }); + + controller.connect(); + await flushPromises(); + + const body = JSON.parse(capturedBody) as Record; + expect(body.workspaceId).toBe("ws-123"); + expect(body.conversationId).toBe("conv-456"); + expect(body.pagePath).toBe("index.html"); + expect(body.userPrompt).toBe("The generated images should convey professionalism."); + + controller.disconnect(); + }); + + it("should set isActive to true on connect", () => { + const { controller } = createController(); + + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ sessionId: "sess-1", status: "generating_prompts" }), { status: 200 }), + ); + + controller.connect(); + + const state = controller as unknown as MockControllerState; + expect(state.isActive).toBe(true); + + controller.disconnect(); + }); + }); + + describe("disconnect", () => { + it("should set isActive to false on disconnect", async () => { + const { controller } = createController(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ sessionId: "sess-1", status: "generating_prompts" }), { status: 200 }), + ); + + controller.connect(); + await flushPromises(); + + controller.disconnect(); + + const state = controller as unknown as MockControllerState; + expect(state.isActive).toBe(false); + }); + + it("should clear polling timeout on disconnect", async () => { + const { controller } = createController(); + const state = controller as unknown as MockControllerState; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ sessionId: "sess-1", status: "generating_prompts" }), { status: 200 }), + ); + + controller.connect(); + await flushPromises(); + + controller.disconnect(); + + expect(state.pollingTimeoutId).toBeNull(); + }); + }); + + describe("poll response handling", () => { + it("should show loading overlay during generating_prompts with no prompts", async () => { + const { controller, elements } = createController(); + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "generating_prompts", + userPrompt: "test", + images: [ + { + id: "img-1", + position: 0, + prompt: null, + suggestedFileName: null, + status: "pending", + imageUrl: null, + errorMessage: null, + }, + ], + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + controller.connect(); + await flushPromises(); // session creation + await flushPromises(); // first poll + + expect(elements.loadingOverlay.classList.contains("hidden")).toBe(false); + expect(elements.mainContent.classList.contains("hidden")).toBe(true); + + controller.disconnect(); + }); + + it("should hide loading overlay once prompts are ready", async () => { + const { controller, elements } = createController(); + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "prompts_ready", + userPrompt: "test", + images: [ + { + id: "img-1", + position: 0, + prompt: "A sunset photo", + suggestedFileName: "sunset.jpg", + status: "pending", + imageUrl: null, + errorMessage: null, + }, + ], + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + controller.connect(); + await flushPromises(); // session creation + await flushPromises(); // first poll + + expect(elements.loadingOverlay.classList.contains("hidden")).toBe(true); + expect(elements.mainContent.classList.contains("hidden")).toBe(false); + + controller.disconnect(); + }); + + it("should not overwrite user prompt textarea when user has edited it and poll runs unfocused", async () => { + const { controller, elements } = createController(); + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "prompts_ready", + userPrompt: "Server value", + images: [ + { + id: "img-1", + position: 0, + prompt: "A sunset photo", + suggestedFileName: "sunset.jpg", + status: "pending", + imageUrl: null, + errorMessage: null, + }, + ], + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + controller.connect(); + await flushPromises(); // session creation + await flushPromises(); // first poll + + const state = controller as unknown as MockControllerState; + elements.userPrompt.value = "Server value"; + state.lastAppliedUserPrompt = "Server value"; + + elements.userPrompt.value = "User edit"; + + await vi.advanceTimersByTimeAsync(1000); // trigger next poll + await flushPromises(); + + expect(elements.userPrompt.value).toBe("User edit"); + + controller.disconnect(); + }); + + it("should set anyGenerating to true when status is generating_prompts", async () => { + const { controller } = createController(); + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "generating_prompts", + images: [], + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + controller.connect(); + await flushPromises(); + await flushPromises(); + + const state = controller as unknown as MockControllerState; + expect(state.anyGenerating).toBe(true); + + controller.disconnect(); + }); + + it("should set anyGenerating to false when all images are completed", async () => { + const { controller } = createController(); + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "images_ready", + images: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "t.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + }, + ], + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + controller.connect(); + await flushPromises(); + await flushPromises(); + + const state = controller as unknown as MockControllerState; + expect(state.anyGenerating).toBe(false); + + controller.disconnect(); + }); + + it("should disable regenerate prompts button while generating", async () => { + const { controller, elements } = createController(); + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "generating_images", + images: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "generating", + imageUrl: null, + errorMessage: null, + }, + ], + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + controller.connect(); + await flushPromises(); + await flushPromises(); + + expect(elements.regeneratePromptsButton.disabled).toBe(true); + + controller.disconnect(); + }); + + it("should enable regenerate prompts button when not generating", async () => { + const { controller, elements } = createController(); + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "images_ready", + images: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "completed", + imageUrl: "/file", + errorMessage: null, + }, + ], + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + controller.connect(); + await flushPromises(); + await flushPromises(); + + expect(elements.regeneratePromptsButton.disabled).toBe(false); + + controller.disconnect(); + }); + + it("should dispatch stateChanged event on each image card", async () => { + const { controller, elements } = createController(); + + const images: ImageData[] = [ + { + id: "img-1", + position: 0, + prompt: "Prompt A", + suggestedFileName: "a.jpg", + status: "completed", + imageUrl: "/a", + errorMessage: null, + }, + { + id: "img-2", + position: 1, + prompt: "Prompt B", + suggestedFileName: "b.jpg", + status: "generating", + imageUrl: null, + errorMessage: null, + }, + ]; + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "generating_images", + images, + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + const card0Handler = vi.fn(); + const card1Handler = vi.fn(); + elements.imageCards[0].addEventListener("photo-builder:stateChanged", card0Handler); + elements.imageCards[1].addEventListener("photo-builder:stateChanged", card1Handler); + + controller.connect(); + await flushPromises(); + await flushPromises(); + + expect(card0Handler).toHaveBeenCalled(); + expect(card1Handler).toHaveBeenCalled(); + + const event0 = card0Handler.mock.calls[0][0] as CustomEvent; + expect(event0.detail.id).toBe("img-1"); + expect(event0.detail.prompt).toBe("Prompt A"); + + const event1 = card1Handler.mock.calls[0][0] as CustomEvent; + expect(event1.detail.id).toBe("img-2"); + expect(event1.detail.status).toBe("generating"); + + controller.disconnect(); + }); + + it("should not dispatch stateChanged again when second poll returns same image data", async () => { + vi.useFakeTimers(); + const { controller, elements } = createController(); + + const images: ImageData[] = [ + { + id: "img-1", + position: 0, + prompt: "Prompt A", + suggestedFileName: "a.jpg", + status: "completed", + imageUrl: "/a", + errorMessage: null, + }, + { + id: "img-2", + position: 1, + prompt: "Prompt B", + suggestedFileName: "b.jpg", + status: "completed", + imageUrl: "/b", + errorMessage: null, + }, + ]; + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "images_ready", + images, + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + const card0Handler = vi.fn(); + const card1Handler = vi.fn(); + elements.imageCards[0].addEventListener("photo-builder:stateChanged", card0Handler); + elements.imageCards[1].addEventListener("photo-builder:stateChanged", card1Handler); + + controller.connect(); + await flushPromises(); + await flushPromises(); + + expect(card0Handler).toHaveBeenCalledTimes(1); + expect(card1Handler).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + await flushPromises(); + + expect(card0Handler).toHaveBeenCalledTimes(1); + expect(card1Handler).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + controller.disconnect(); + }); + + it("should use longer poll interval when session is images_ready and nothing generating", async () => { + vi.useFakeTimers(); + const { controller } = createController(); + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "images_ready", + images: [ + { + id: "img-1", + position: 0, + prompt: "A", + suggestedFileName: "a.jpg", + status: "completed", + imageUrl: "/a", + errorMessage: null, + }, + ], + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + controller.connect(); + await flushPromises(); + await flushPromises(); + expect(fetchCallCount).toBe(2); + + await vi.advanceTimersByTimeAsync(1000); + await flushPromises(); + expect(fetchCallCount).toBe(2); + + await vi.advanceTimersByTimeAsync(4000); + await flushPromises(); + expect(fetchCallCount).toBe(3); + + vi.useRealTimers(); + controller.disconnect(); + }); + + it("should dispatch stateChanged to all cards when anyGenerating transitions even if image data unchanged", async () => { + vi.useFakeTimers(); + const { controller, elements } = createController(); + + const images: ImageData[] = [ + { + id: "img-1", + position: 0, + prompt: "Prompt A", + suggestedFileName: "a.jpg", + status: "completed", + imageUrl: "/a", + errorMessage: null, + }, + { + id: "img-2", + position: 1, + prompt: "Prompt B", + suggestedFileName: "b.jpg", + status: "completed", + imageUrl: "/b", + errorMessage: null, + }, + ]; + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + // First poll: session still generating_images, but images already completed + const pollResponse1: SessionResponse = { status: "generating_images", images }; + // Second poll: session transitions to images_ready, same image data + const pollResponse2: SessionResponse = { status: "images_ready", images }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + if (fetchCallCount === 2) { + return new Response(JSON.stringify(pollResponse1), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse2), { status: 200 }); + }); + + const card0Handler = vi.fn(); + const card1Handler = vi.fn(); + elements.imageCards[0].addEventListener("photo-builder:stateChanged", card0Handler); + elements.imageCards[1].addEventListener("photo-builder:stateChanged", card1Handler); + + // First poll (generating_images): dispatches because data is new + controller.connect(); + await flushPromises(); + await flushPromises(); + expect(card0Handler).toHaveBeenCalledTimes(1); + expect(card1Handler).toHaveBeenCalledTimes(1); + + // Second poll (images_ready): image data unchanged but anyGenerating transitions false + await vi.advanceTimersByTimeAsync(1000); + await flushPromises(); + expect(card0Handler).toHaveBeenCalledTimes(2); + expect(card1Handler).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + controller.disconnect(); + }); + + it("should set data-photo-builder-generating attribute on element", async () => { + const { controller, elements } = createController(); + + const createResponse: SessionResponse = { sessionId: "sess-1", status: "generating_prompts" }; + const pollResponse: SessionResponse = { + status: "generating_images", + images: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "generating", + imageUrl: null, + errorMessage: null, + }, + ], + }; + + let fetchCallCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + fetchCallCount++; + if (fetchCallCount === 1) { + return new Response(JSON.stringify(createResponse), { status: 200 }); + } + return new Response(JSON.stringify(pollResponse), { status: 200 }); + }); + + controller.connect(); + await flushPromises(); + await flushPromises(); + + expect(elements.controllerElement.getAttribute("data-photo-builder-generating")).toBe("true"); + + controller.disconnect(); + }); + }); + + describe("regeneratePrompts", () => { + it("should not send request when sessionId is null", async () => { + const { controller } = createController(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + await controller.regeneratePrompts(); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should not send request when anyGenerating is true", async () => { + const { controller } = createController(); + const state = controller as unknown as MockControllerState; + state.sessionId = "sess-1"; + state.anyGenerating = true; + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + await controller.regeneratePrompts(); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should send regenerate request with user prompt and kept image IDs", async () => { + const { controller, elements } = createController(); + const state = controller as unknown as MockControllerState; + state.sessionId = "sess-1"; + state.anyGenerating = false; + elements.userPrompt.value = "Updated user prompt"; + + // Set up a card with a checked keep checkbox + const keepCheckbox = document.createElement("input"); + keepCheckbox.type = "checkbox"; + keepCheckbox.checked = true; + keepCheckbox.setAttribute("data-photo-image-target", "keepCheckbox"); + elements.imageCards[1].appendChild(keepCheckbox); + elements.imageCards[1].setAttribute("data-photo-image-image-id", "img-2"); + + let capturedUrl = ""; + let capturedBody = ""; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + capturedUrl = url as string; + if (options && typeof options === "object" && "body" in options) { + capturedBody = options.body as string; + } + return new Response("ok"); + }); + + await controller.regeneratePrompts(); + + expect(capturedUrl).toBe("/api/photo-builder/sessions/sess-1/regenerate-prompts"); + const body = JSON.parse(capturedBody) as { userPrompt: string; keepImageIds: string[] }; + expect(body.userPrompt).toBe("Updated user prompt"); + expect(body.keepImageIds).toContain("img-2"); + }); + }); + + describe("handlePromptEdited", () => { + it("should send prompt update to backend after debounce", async () => { + vi.useFakeTimers(); + const { controller } = createController(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + const event = new CustomEvent("photo-image:promptEdited", { + detail: { + position: 0, + imageId: "img-1", + prompt: "A beautiful landscape", + }, + }); + + controller.handlePromptEdited(event); + expect(globalThis.fetch).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(400); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/photo-builder/images/img-1/update-prompt", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ prompt: "A beautiful landscape" }), + }), + ); + vi.useRealTimers(); + }); + + it("should debounce rapid promptEdited events to a single fetch", async () => { + vi.useFakeTimers(); + const { controller } = createController(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + controller.handlePromptEdited( + new CustomEvent("photo-image:promptEdited", { + detail: { position: 0, imageId: "img-1", prompt: "A" }, + }), + ); + controller.handlePromptEdited( + new CustomEvent("photo-image:promptEdited", { + detail: { position: 0, imageId: "img-1", prompt: "AB" }, + }), + ); + controller.handlePromptEdited( + new CustomEvent("photo-image:promptEdited", { + detail: { position: 0, imageId: "img-1", prompt: "ABC" }, + }), + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(400); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ prompt: "ABC" }), + }), + ); + vi.useRealTimers(); + }); + + it("should not send request when imageId is missing", () => { + const { controller } = createController(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + const event = new CustomEvent("photo-image:promptEdited", { + detail: { + position: 0, + imageId: "", + prompt: "test", + }, + }); + + controller.handlePromptEdited(event); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + }); + + describe("handleRegenerateImage", () => { + it("should send regenerate request for the image", async () => { + const { controller } = createController(); + const state = controller as unknown as MockControllerState; + state.anyGenerating = false; + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + const event = new CustomEvent("photo-image:regenerateRequested", { + detail: { + position: 2, + imageId: "img-3", + prompt: "An office scene", + }, + }); + + await controller.handleRegenerateImage(event); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/photo-builder/images/img-3/regenerate", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("should not send request when anyGenerating is true", async () => { + const { controller } = createController(); + const state = controller as unknown as MockControllerState; + state.anyGenerating = true; + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + const event = new CustomEvent("photo-image:regenerateRequested", { + detail: { position: 0, imageId: "img-1", prompt: "test" }, + }); + + await controller.handleRegenerateImage(event); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + }); + + describe("handleUploadToMediaStore", () => { + it("should send upload request for the image", async () => { + const { controller } = createController(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + const event = new CustomEvent("photo-image:uploadRequested", { + detail: { + position: 0, + imageId: "img-1", + suggestedFileName: "sunset.jpg", + }, + }); + + await controller.handleUploadToMediaStore(event); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/photo-builder/images/img-1/upload-to-media-store", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("should not send request when imageId is missing", async () => { + const { controller } = createController(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + const event = new CustomEvent("photo-image:uploadRequested", { + detail: { position: 0, imageId: "", suggestedFileName: "" }, + }); + + await controller.handleUploadToMediaStore(event); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + }); + + describe("switchResolution", () => { + it("should send regenerate-all-images request with imageSize=2K when switching to hi-res", async () => { + const loresButton = document.createElement("button"); + loresButton.dataset.imageSize = "1K"; + const hiresButton = document.createElement("button"); + hiresButton.dataset.imageSize = "2K"; + + const { controller } = createController({ + sessionId: "sess-1", + supportsResolutionToggleValue: true, + hasLoresButtonTarget: true, + loresButtonTarget: loresButton, + hasHiresButtonTarget: true, + hiresButtonTarget: hiresButton, + currentImageSize: "1K", + }); + + let capturedUrl = ""; + let capturedBody = ""; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + capturedUrl = url as string; + if (options && typeof options === "object" && "body" in options) { + capturedBody = options.body as string; + } + return new Response(JSON.stringify({ status: "ok" })); + }); + + const event = { currentTarget: hiresButton } as unknown as Event; + await controller.switchResolution(event); + + expect(capturedUrl).toBe("/api/photo-builder/sessions/sess-1/regenerate-all-images"); + const body = JSON.parse(capturedBody) as Record; + expect(body.imageSize).toBe("2K"); + }); + + it("should send regenerate-all-images request with imageSize=1K when switching to lo-res", async () => { + const loresButton = document.createElement("button"); + loresButton.dataset.imageSize = "1K"; + const hiresButton = document.createElement("button"); + hiresButton.dataset.imageSize = "2K"; + + const { controller } = createController({ + sessionId: "sess-1", + supportsResolutionToggleValue: true, + hasLoresButtonTarget: true, + loresButtonTarget: loresButton, + hasHiresButtonTarget: true, + hiresButtonTarget: hiresButton, + currentImageSize: "2K", // currently hi-res + }); + + let capturedBody = ""; + vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, options) => { + if (options && typeof options === "object" && "body" in options) { + capturedBody = options.body as string; + } + return new Response(JSON.stringify({ status: "ok" })); + }); + + const event = { currentTarget: loresButton } as unknown as Event; + await controller.switchResolution(event); + + const body = JSON.parse(capturedBody) as Record; + expect(body.imageSize).toBe("1K"); + }); + + it("should not send request when already at the selected resolution", async () => { + const loresButton = document.createElement("button"); + loresButton.dataset.imageSize = "1K"; + + const { controller } = createController({ + sessionId: "sess-1", + supportsResolutionToggleValue: true, + hasLoresButtonTarget: true, + loresButtonTarget: loresButton, + hasHiresButtonTarget: true, + hiresButtonTarget: document.createElement("button"), + currentImageSize: "1K", + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + const event = { currentTarget: loresButton } as unknown as Event; + await controller.switchResolution(event); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should not send request when anyGenerating is true", async () => { + const hiresButton = document.createElement("button"); + hiresButton.dataset.imageSize = "2K"; + + const { controller } = createController({ + sessionId: "sess-1", + anyGenerating: true, + supportsResolutionToggleValue: true, + hasLoresButtonTarget: true, + loresButtonTarget: document.createElement("button"), + hasHiresButtonTarget: true, + hiresButtonTarget: hiresButton, + currentImageSize: "1K", + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + const event = { currentTarget: hiresButton } as unknown as Event; + await controller.switchResolution(event); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should include current imageSize in single image regenerate request", async () => { + const { controller } = createController({ + currentImageSize: "1K", + }); + const state = controller as unknown as MockControllerState; + state.anyGenerating = false; + + let capturedBody = ""; + vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, options) => { + if (options && typeof options === "object" && "body" in options) { + capturedBody = options.body as string; + } + return new Response("ok"); + }); + + const event = new CustomEvent("photo-image:regenerateRequested", { + detail: { position: 2, imageId: "img-3", prompt: "An office scene" }, + }); + + await controller.handleRegenerateImage(event); + + const body = JSON.parse(capturedBody) as Record; + expect(body.imageSize).toBe("1K"); + }); + }); + + describe("embedIntoPage", () => { + it("should navigate to editor with prefilled message when all images already uploaded", async () => { + const { controller } = createController(); + const state = controller as unknown as MockControllerState; + state.lastImages = [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "office-scene.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + uploadedToMediaStore: true, + uploadedFileName: "00fa0883ee6db2e2_office-scene.jpg", + }, + { + id: "img-2", + position: 1, + prompt: "test", + suggestedFileName: "team-photo.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + uploadedToMediaStore: true, + uploadedFileName: "abc123_team-photo.jpg", + }, + { + id: "img-3", + position: 2, + prompt: "test", + suggestedFileName: null, + status: "failed", + imageUrl: null, + errorMessage: "Error", + }, + ]; + + // Mock window.location.href + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + Object.defineProperty(window.location, "href", { + set: hrefSetter, + get: () => "", + }); + + await controller.embedIntoPage(); + + expect(hrefSetter).toHaveBeenCalled(); + const url = hrefSetter.mock.calls[0][0] as string; + expect(url).toContain("/conversation/conv-456?prefill="); + expect(url).toContain(encodeURIComponent("00fa0883ee6db2e2_office-scene.jpg")); + expect(url).toContain(encodeURIComponent("abc123_team-photo.jpg")); + expect(url).toContain(encodeURIComponent("index.html")); + }); + + it("should upload non-uploaded images then navigate with actual S3 filenames", async () => { + const uploadingOverlay = document.createElement("div"); + uploadingOverlay.classList.add("hidden"); + + const { controller } = createController({ + hasRemoteAssetsValue: true, + hasUploadingImagesOverlayTarget: true, + uploadingImagesOverlayTarget: uploadingOverlay, + lastImages: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "office.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + uploadedToMediaStore: false, + }, + { + id: "img-2", + position: 1, + prompt: "test", + suggestedFileName: "team.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + uploadedToMediaStore: true, + uploadedFileName: "abc123_team.jpg", + }, + ], + }); + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + url: "https://s3.example/uploads/20260211/00fa0883ee6db2e2_office.jpg", + fileName: "office.jpg", + uploadedFileName: "00fa0883ee6db2e2_office.jpg", + }), + ), + ); + + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + Object.defineProperty(window.location, "href", { + set: hrefSetter, + get: () => "", + }); + + await controller.embedIntoPage(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/photo-builder/images/img-1/upload-to-media-store", + expect.objectContaining({ method: "POST" }), + ); + expect(hrefSetter).toHaveBeenCalled(); + const url = hrefSetter.mock.calls[0][0] as string; + expect(url).toContain("/conversation/conv-456?prefill="); + expect(url).toContain(encodeURIComponent("00fa0883ee6db2e2_office.jpg")); + expect(url).toContain(encodeURIComponent("abc123_team.jpg")); + }); + + it("should not navigate when upload fails", async () => { + const uploadingOverlay = document.createElement("div"); + uploadingOverlay.classList.add("hidden"); + + const { controller } = createController({ + hasRemoteAssetsValue: true, + hasUploadingImagesOverlayTarget: true, + uploadingImagesOverlayTarget: uploadingOverlay, + lastImages: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "office.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + uploadedToMediaStore: false, + }, + ], + }); + + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("error", { status: 500 })); + + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + Object.defineProperty(window.location, "href", { + set: hrefSetter, + get: () => "", + }); + + await controller.embedIntoPage(); + + expect(hrefSetter).not.toHaveBeenCalled(); + }); + + it("should poll manifest availability after upload before navigating", async () => { + const uploadingOverlay = document.createElement("div"); + uploadingOverlay.classList.add("hidden"); + const waitingOverlay = document.createElement("div"); + waitingOverlay.classList.add("hidden"); + + const { controller } = createController({ + hasRemoteAssetsValue: true, + hasUploadingImagesOverlayTarget: true, + uploadingImagesOverlayTarget: uploadingOverlay, + hasWaitingForManifestOverlayTarget: true, + waitingForManifestOverlayTarget: waitingOverlay, + checkManifestAvailabilityUrlPatternValue: "/api/photo-builder/ws-123/check-manifest-availability", + lastImages: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "office.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + uploadedToMediaStore: false, + }, + ], + }); + + const fetchCalls: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + fetchCalls.push(url as string); + // First call: upload to media store + if ((url as string).includes("upload-to-media-store")) { + return new Response( + JSON.stringify({ + url: "https://s3.example/00fa0883_office.jpg", + fileName: "office.jpg", + uploadedFileName: "00fa0883_office.jpg", + }), + ); + } + // Second call: check manifest availability => all available + return new Response( + JSON.stringify({ + available: ["00fa0883_office.jpg"], + allAvailable: true, + }), + ); + }); + + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + Object.defineProperty(window.location, "href", { + set: hrefSetter, + get: () => "", + }); + + await controller.embedIntoPage(); + + // Should have called upload, then check-manifest-availability + expect(fetchCalls.some((u) => u.includes("upload-to-media-store"))).toBe(true); + expect(fetchCalls.some((u) => u.includes("check-manifest-availability"))).toBe(true); + + // Should have navigated + expect(hrefSetter).toHaveBeenCalled(); + const navUrl = hrefSetter.mock.calls[0][0] as string; + expect(navUrl).toContain(encodeURIComponent("00fa0883_office.jpg")); + }); + + it("should show and hide waiting overlay during manifest polling", async () => { + const waitingOverlay = document.createElement("div"); + waitingOverlay.classList.add("hidden"); + + const { controller } = createController({ + hasRemoteAssetsValue: true, + hasWaitingForManifestOverlayTarget: true, + waitingForManifestOverlayTarget: waitingOverlay, + checkManifestAvailabilityUrlPatternValue: "/api/photo-builder/ws-123/check-manifest-availability", + lastImages: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "office.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + uploadedToMediaStore: true, + uploadedFileName: "00fa0883_office.jpg", + }, + ], + }); + + let overlayShown = false; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + // Check that overlay is visible during the fetch + if (!waitingOverlay.classList.contains("hidden")) { + overlayShown = true; + } + return new Response( + JSON.stringify({ + available: ["00fa0883_office.jpg"], + allAvailable: true, + }), + ); + }); + + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + Object.defineProperty(window.location, "href", { + set: hrefSetter, + get: () => "", + }); + + await controller.embedIntoPage(); + + expect(overlayShown).toBe(true); + // After completion, overlay should be hidden + expect(waitingOverlay.classList.contains("hidden")).toBe(true); + }); + + it("should skip manifest polling when checkManifestAvailabilityUrlPattern is empty", async () => { + const { controller } = createController({ + checkManifestAvailabilityUrlPatternValue: "", + lastImages: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "office.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + uploadedToMediaStore: true, + uploadedFileName: "00fa0883_office.jpg", + }, + ], + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + Object.defineProperty(window.location, "href", { + set: hrefSetter, + get: () => "", + }); + + await controller.embedIntoPage(); + + // Should not have called any fetch (no uploads needed, no manifest check needed) + expect(fetchSpy).not.toHaveBeenCalled(); + // Should have navigated directly + expect(hrefSetter).toHaveBeenCalled(); + }); + + it("should navigate even if manifest polling times out", async () => { + const waitingOverlay = document.createElement("div"); + waitingOverlay.classList.add("hidden"); + + const { controller } = createController({ + hasRemoteAssetsValue: true, + hasWaitingForManifestOverlayTarget: true, + waitingForManifestOverlayTarget: waitingOverlay, + checkManifestAvailabilityUrlPatternValue: "/api/photo-builder/ws-123/check-manifest-availability", + lastImages: [ + { + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "office.jpg", + status: "completed", + imageUrl: "/file", + errorMessage: null, + uploadedToMediaStore: true, + uploadedFileName: "00fa0883_office.jpg", + }, + ], + }); + + // Always return allAvailable: false + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + available: [], + allAvailable: false, + }), + ), + ); + + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + Object.defineProperty(window.location, "href", { + set: hrefSetter, + get: () => "", + }); + + // Run embedIntoPage - this will loop through all 30 poll attempts + // Since we're using fake timers, we need to advance through the setTimeout calls + const embedPromise = controller.embedIntoPage(); + + // Advance through all 30 poll intervals (30 * 3000ms = 90000ms) + for (let i = 0; i < 30; i++) { + await flushPromises(); + await vi.advanceTimersByTimeAsync(3000); + } + await flushPromises(); + + await embedPromise; + + // Should still navigate even after timeout + expect(hrefSetter).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/frontend/unit/PhotoBuilder/photo_image_controller.test.ts b/tests/frontend/unit/PhotoBuilder/photo_image_controller.test.ts new file mode 100644 index 0000000..a666d1f --- /dev/null +++ b/tests/frontend/unit/PhotoBuilder/photo_image_controller.test.ts @@ -0,0 +1,710 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import PhotoImageController from "../../../../src/PhotoBuilder/Presentation/Resources/assets/controllers/photo_image_controller.ts"; + +/** + * Unit tests for the PhotoImage Stimulus controller. + * Tests image state updates, prompt editing, event dispatching, and button state management. + */ + +interface MockControllerState { + positionValue: number; + hasMediaStoreValue: boolean; + generatingPromptTextValue: string; + imageTarget: HTMLImageElement; + placeholderTarget: HTMLElement; + promptTextareaTarget: HTMLTextAreaElement; + keepCheckboxTarget: HTMLInputElement; + regenerateButtonTarget: HTMLButtonElement; + hasUploadButtonTarget: boolean; + uploadButtonTarget: HTMLButtonElement; + statusBadgeTarget: HTMLElement; + imageId: string | null; + currentStatus: string; + suggestedFileName: string | null; + promptAwaitingRegenerate: boolean; + promptBeforeRegenerate: string | null; +} + +interface ImageStateDetail { + id: string; + position: number; + prompt: string | null; + suggestedFileName: string | null; + status: string; + imageUrl: string | null; + errorMessage: string | null; +} + +const createController = ( + overrides: Partial = {}, +): { + controller: PhotoImageController; + elements: { + controllerElement: HTMLElement; + image: HTMLImageElement; + placeholder: HTMLElement; + promptTextarea: HTMLTextAreaElement; + keepCheckbox: HTMLInputElement; + regenerateButton: HTMLButtonElement; + uploadButton: HTMLButtonElement; + statusBadge: HTMLElement; + }; +} => { + const controllerElement = document.createElement("div"); + const image = document.createElement("img"); + image.classList.add("hidden"); + const placeholder = document.createElement("div"); + const promptTextarea = document.createElement("textarea"); + const keepCheckbox = document.createElement("input"); + keepCheckbox.type = "checkbox"; + const regenerateButton = document.createElement("button"); + const uploadButton = document.createElement("button"); + const statusBadge = document.createElement("span"); + statusBadge.classList.add("hidden"); + + controllerElement.appendChild(image); + controllerElement.appendChild(placeholder); + controllerElement.appendChild(promptTextarea); + controllerElement.appendChild(keepCheckbox); + controllerElement.appendChild(regenerateButton); + controllerElement.appendChild(uploadButton); + controllerElement.appendChild(statusBadge); + + const controller = Object.create(PhotoImageController.prototype) as PhotoImageController; + const state = controller as unknown as MockControllerState; + + state.positionValue = 0; + state.hasMediaStoreValue = false; + state.generatingPromptTextValue = "Generating..."; + state.promptAwaitingRegenerate = false; + state.promptBeforeRegenerate = null; + state.imageTarget = image; + state.placeholderTarget = placeholder; + state.promptTextareaTarget = promptTextarea; + state.keepCheckboxTarget = keepCheckbox; + state.regenerateButtonTarget = regenerateButton; + state.hasUploadButtonTarget = true; + state.uploadButtonTarget = uploadButton; + state.statusBadgeTarget = statusBadge; + state.imageId = null; + state.currentStatus = "pending"; + state.suggestedFileName = null; + + Object.assign(state, overrides); + + Object.defineProperty(controller, "element", { + get: () => controllerElement, + configurable: true, + }); + + // Mock dispatch to capture events + (controller as unknown as { dispatch: (name: string, options: object) => void }).dispatch = vi.fn( + (name: string, options: { detail: object }) => { + controllerElement.dispatchEvent( + new CustomEvent(`photo-image:${name}`, { + detail: options.detail, + bubbles: true, + }), + ); + }, + ); + + return { + controller, + elements: { + controllerElement, + image, + placeholder, + promptTextarea, + keepCheckbox, + regenerateButton, + uploadButton, + statusBadge, + }, + }; +}; + +const makeStateEvent = (data: ImageStateDetail): CustomEvent => { + return new CustomEvent("photo-builder:stateChanged", { detail: data }); +}; + +describe("PhotoImageController", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("updateFromState", () => { + it("should set imageId and currentStatus from event data", () => { + const { controller } = createController(); + const state = controller as unknown as MockControllerState; + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "A sunset", + suggestedFileName: "sunset.jpg", + status: "completed", + imageUrl: "/images/img-1/file", + errorMessage: null, + }), + ); + + expect(state.imageId).toBe("img-1"); + expect(state.currentStatus).toBe("completed"); + expect(state.suggestedFileName).toBe("sunset.jpg"); + }); + + it("should store imageId as data attribute on the element", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-42", + position: 0, + prompt: null, + suggestedFileName: null, + status: "pending", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.controllerElement.getAttribute("data-photo-image-image-id")).toBe("img-42"); + }); + + it("should update prompt textarea with prompt from event", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "A professional office photo", + suggestedFileName: "office.jpg", + status: "pending", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.promptTextarea.value).toBe("A professional office photo"); + }); + + it("should not update prompt textarea when it is focused", () => { + const { controller, elements } = createController(); + document.body.appendChild(elements.controllerElement); + elements.promptTextarea.value = "User edited prompt"; + elements.promptTextarea.focus(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "Server prompt", + suggestedFileName: null, + status: "pending", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.promptTextarea.value).toBe("User edited prompt"); + }); + + it("should not update prompt textarea when prompt is null", () => { + const { controller, elements } = createController(); + elements.promptTextarea.value = "Existing prompt"; + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: null, + suggestedFileName: null, + status: "pending", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.promptTextarea.value).toBe("Existing prompt"); + }); + }); + + describe("image display", () => { + it("should show image and hide placeholder when completed with imageUrl", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "test.jpg", + status: "completed", + imageUrl: "/api/photo-builder/images/img-1/file", + errorMessage: null, + }), + ); + + expect(elements.image.src).toContain("/api/photo-builder/images/img-1/file"); + expect(elements.image.classList.contains("hidden")).toBe(false); + expect(elements.placeholder.classList.contains("hidden")).toBe(true); + }); + + it("should not change img.src when updateFromState is called again with same imageUrl", () => { + const { controller, elements } = createController(); + const imageUrl = "/api/photo-builder/images/img-1/file"; + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "test.jpg", + status: "completed", + imageUrl, + errorMessage: null, + }), + ); + const srcAfterFirst = elements.image.src; + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: "test.jpg", + status: "completed", + imageUrl, + errorMessage: null, + }), + ); + + expect(elements.image.src).toBe(srcAfterFirst); + }); + + it("should hide image and show placeholder when generating", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "generating", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.image.classList.contains("hidden")).toBe(true); + expect(elements.placeholder.classList.contains("hidden")).toBe(false); + }); + + it("should hide image and show placeholder when pending", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "pending", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.image.classList.contains("hidden")).toBe(true); + expect(elements.placeholder.classList.contains("hidden")).toBe(false); + }); + + it("should show error message in placeholder when failed", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "failed", + imageUrl: null, + errorMessage: "Rate limit exceeded", + }), + ); + + expect(elements.image.classList.contains("hidden")).toBe(true); + expect(elements.placeholder.classList.contains("hidden")).toBe(false); + expect(elements.placeholder.innerHTML).toContain("Rate limit exceeded"); + }); + + it("should show default error message when failed without errorMessage", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "failed", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.placeholder.innerHTML).toContain("Generation failed"); + }); + }); + + describe("status badge", () => { + it("should show Done badge when completed", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "completed", + imageUrl: "/file", + errorMessage: null, + }), + ); + + expect(elements.statusBadge.textContent).toBe("Done"); + expect(elements.statusBadge.classList.contains("hidden")).toBe(false); + }); + + it("should show Generating badge when generating", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "generating", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.statusBadge.textContent).toBe("Generating..."); + expect(elements.statusBadge.classList.contains("hidden")).toBe(false); + }); + + it("should show Failed badge when failed", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "failed", + imageUrl: null, + errorMessage: "Error", + }), + ); + + expect(elements.statusBadge.textContent).toBe("Failed"); + expect(elements.statusBadge.classList.contains("hidden")).toBe(false); + }); + + it("should hide badge for pending status", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "pending", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.statusBadge.classList.contains("hidden")).toBe(true); + }); + }); + + describe("button states", () => { + it("should disable regenerate button when parent is generating", () => { + const { controller, elements } = createController(); + const parentDiv = document.createElement("div"); + parentDiv.setAttribute("data-photo-builder-generating", "true"); + parentDiv.appendChild(elements.controllerElement); + document.body.appendChild(parentDiv); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "completed", + imageUrl: "/file", + errorMessage: null, + }), + ); + + expect(elements.regenerateButton.disabled).toBe(true); + }); + + it("should enable regenerate button when parent is not generating and status is not generating", () => { + const { controller, elements } = createController(); + const parentDiv = document.createElement("div"); + parentDiv.setAttribute("data-photo-builder-generating", "false"); + parentDiv.appendChild(elements.controllerElement); + document.body.appendChild(parentDiv); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "completed", + imageUrl: "/file", + errorMessage: null, + }), + ); + + expect(elements.regenerateButton.disabled).toBe(false); + }); + + it("should disable upload button when status is not completed", () => { + const { controller, elements } = createController(); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "generating", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.uploadButton.disabled).toBe(true); + }); + + it("should enable upload button when status is completed and no generation in progress", () => { + const { controller, elements } = createController(); + const parentDiv = document.createElement("div"); + parentDiv.setAttribute("data-photo-builder-generating", "false"); + parentDiv.appendChild(elements.controllerElement); + document.body.appendChild(parentDiv); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "test", + suggestedFileName: null, + status: "completed", + imageUrl: "/file", + errorMessage: null, + }), + ); + + expect(elements.uploadButton.disabled).toBe(false); + }); + }); + + describe("clearPromptIfNotKept and prompt regeneration", () => { + it("should apply new prompt when status is pending after clearPromptIfNotKept", () => { + const { controller, elements } = createController(); + elements.promptTextarea.value = "Old prompt"; + + controller.clearPromptIfNotKept(); + expect(elements.promptTextarea.value).toBe("Generating..."); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "New prompt from AI", + suggestedFileName: null, + status: "pending", + imageUrl: null, + errorMessage: null, + }), + ); + + expect(elements.promptTextarea.value).toBe("New prompt from AI"); + }); + + it("should apply new prompt even when status is already completed (fast generation)", () => { + const { controller, elements } = createController(); + elements.promptTextarea.value = "Old prompt"; + + controller.clearPromptIfNotKept(); + expect(elements.promptTextarea.value).toBe("Generating..."); + + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "New prompt from AI", + suggestedFileName: "img.jpg", + status: "completed", + imageUrl: "/img-1/file", + errorMessage: null, + }), + ); + + expect(elements.promptTextarea.value).toBe("New prompt from AI"); + expect(elements.promptTextarea.classList.contains("animate-pulse")).toBe(false); + }); + + it("should not apply old prompt while awaiting regeneration", () => { + const { controller, elements } = createController(); + elements.promptTextarea.value = "Old prompt"; + + controller.clearPromptIfNotKept(); + expect(elements.promptTextarea.value).toBe("Generating..."); + + // Poll returns old data (backend hasn't processed regeneration yet) + controller.updateFromState( + makeStateEvent({ + id: "img-1", + position: 0, + prompt: "Old prompt", + suggestedFileName: null, + status: "completed", + imageUrl: "/img-1/file", + errorMessage: null, + }), + ); + + // Should stay at "Generating..." because prompt hasn't changed + expect(elements.promptTextarea.value).toBe("Generating..."); + expect(elements.promptTextarea.classList.contains("animate-pulse")).toBe(true); + }); + }); + + describe("onPromptInput", () => { + it("should auto-check the keep checkbox", () => { + const { controller, elements } = createController(); + elements.keepCheckbox.checked = false; + + controller.onPromptInput(); + + expect(elements.keepCheckbox.checked).toBe(true); + }); + + it("should dispatch promptEdited event with position and prompt", () => { + const { controller, elements } = createController({ positionValue: 2 }); + const state = controller as unknown as MockControllerState; + state.imageId = "img-5"; + elements.promptTextarea.value = "Updated prompt text"; + + const eventHandler = vi.fn(); + elements.controllerElement.addEventListener("photo-image:promptEdited", eventHandler); + + controller.onPromptInput(); + + expect(eventHandler).toHaveBeenCalled(); + const event = eventHandler.mock.calls[0][0] as CustomEvent; + expect(event.detail.position).toBe(2); + expect(event.detail.imageId).toBe("img-5"); + expect(event.detail.prompt).toBe("Updated prompt text"); + }); + }); + + describe("requestRegenerate", () => { + it("should dispatch regenerateRequested event when imageId is set", () => { + const { controller, elements } = createController({ positionValue: 1 }); + const state = controller as unknown as MockControllerState; + state.imageId = "img-3"; + elements.promptTextarea.value = "A mountain landscape"; + + const eventHandler = vi.fn(); + elements.controllerElement.addEventListener("photo-image:regenerateRequested", eventHandler); + + controller.requestRegenerate(); + + expect(eventHandler).toHaveBeenCalled(); + const event = eventHandler.mock.calls[0][0] as CustomEvent; + expect(event.detail.position).toBe(1); + expect(event.detail.imageId).toBe("img-3"); + expect(event.detail.prompt).toBe("A mountain landscape"); + }); + + it("should not dispatch event when imageId is null", () => { + const { controller, elements } = createController(); + + const eventHandler = vi.fn(); + elements.controllerElement.addEventListener("photo-image:regenerateRequested", eventHandler); + + controller.requestRegenerate(); + + expect(eventHandler).not.toHaveBeenCalled(); + }); + }); + + describe("requestUpload", () => { + it("should dispatch uploadRequested event with suggestedFileName", () => { + const { controller, elements } = createController({ positionValue: 3 }); + const state = controller as unknown as MockControllerState; + state.imageId = "img-7"; + state.suggestedFileName = "cozy-cafe.jpg"; + + const eventHandler = vi.fn(); + elements.controllerElement.addEventListener("photo-image:uploadRequested", eventHandler); + + controller.requestUpload(); + + expect(eventHandler).toHaveBeenCalled(); + const event = eventHandler.mock.calls[0][0] as CustomEvent; + expect(event.detail.position).toBe(3); + expect(event.detail.imageId).toBe("img-7"); + expect(event.detail.suggestedFileName).toBe("cozy-cafe.jpg"); + }); + + it("should use empty string when suggestedFileName is null", () => { + const { controller, elements } = createController(); + const state = controller as unknown as MockControllerState; + state.imageId = "img-8"; + state.suggestedFileName = null; + + const eventHandler = vi.fn(); + elements.controllerElement.addEventListener("photo-image:uploadRequested", eventHandler); + + controller.requestUpload(); + + expect(eventHandler).toHaveBeenCalled(); + const event = eventHandler.mock.calls[0][0] as CustomEvent; + expect(event.detail.suggestedFileName).toBe(""); + }); + + it("should not dispatch event when imageId is null", () => { + const { controller, elements } = createController(); + + const eventHandler = vi.fn(); + elements.controllerElement.addEventListener("photo-image:uploadRequested", eventHandler); + + controller.requestUpload(); + + expect(eventHandler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/frontend/unit/ProjectMgmt/llm_key_verification_controller.test.ts b/tests/frontend/unit/ProjectMgmt/llm_key_verification_controller.test.ts index 7f065e4..5c3620f 100644 --- a/tests/frontend/unit/ProjectMgmt/llm_key_verification_controller.test.ts +++ b/tests/frontend/unit/ProjectMgmt/llm_key_verification_controller.test.ts @@ -22,27 +22,29 @@ describe("LlmKeyVerificationController", () => { const createControllerElement = async (apiKey: string = ""): Promise => { const html = ` -
    - - -
    - - - +
    + +
    + +
    + + + +
    -
    + `; document.body.innerHTML = html; // Wait for Stimulus to connect the controller await new Promise((resolve) => setTimeout(resolve, 50)); - return document.body.firstElementChild as HTMLDivElement; + return document.body.querySelector('[data-controller="llm-key-verification"]') as HTMLDivElement; }; it("should not verify when input is empty", async () => { diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 0068e40..8923a9f 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -100,10 +100,18 @@ project: github_access_key: "GitHub-Zugriffsschlüssel" placeholder_github_token: "ghp_xxxxxxxxxxxx" github_access_key_help: "Ein persönlicher Zugriffstoken, damit wir das Projekt öffnen und Änderungen speichern können." - llm_provider: "LLM-Modellanbieter" - llm_api_key: "LLM-API-Schlüssel" + content_editing_llm_provider: "Inhaltsbearbeitung — LLM-Modellanbieter" + content_editing_api_key: "Inhaltsbearbeitung — LLM-API-Schlüssel" placeholder_llm_api_key: "sk-..." - llm_api_key_help: "Ihr API-Schlüssel für den ausgewählten LLM-Anbieter." + content_editing_api_key_help: "Ihr API-Schlüssel für den ausgewählten LLM-Anbieter (für die Inhaltsbearbeitung)." + photo_builder_llm_settings: "PhotoBuilder — Bildgenerierungs-Einstellungen" + photo_builder_llm_settings_help: "Wählen Sie, wie Bilder im PhotoBuilder generiert werden." + photo_builder_mode_reuse: "Inhaltsbearbeitungs-LLM-Einstellungen für Bildgenerierung verwenden" + photo_builder_mode_reuse_help: "PhotoBuilder verwendet denselben OpenAI-API-Schlüssel und Anbieter wie oben konfiguriert." + photo_builder_mode_dedicated: "Eigene LLM-Einstellungen für Bildgenerierung verwenden" + photo_builder_mode_dedicated_help: "Wählen Sie einen spezifischen Anbieter und API-Schlüssel für die PhotoBuilder-Bildgenerierung." + photo_builder_provider: "Bildgenerierungs-Anbieter" + photo_builder_api_key: "Bildgenerierungs-API-Schlüssel" verifying_key: "Schlüssel wird überprüft..." key_verified: "Schlüssel erfolgreich überprüft" key_verification_failed: "Schlüsselüberprüfung fehlgeschlagen" @@ -197,6 +205,11 @@ editor: session_finished: "Diese Sitzung ist beendet. Sie können den Verlauf oben einsehen." cannot_edit: "Sie können diese Sitzung nicht bearbeiten." preview_pages: "Vorschauseiten" + preview_pages_description: "Vorschau und Bearbeitung der generierten Seiten" + edit_html: "HTML bearbeiten" + preview: "Vorschau" + photo_builder_heading: "Bilder generieren" + photo_builder_description: "KI-gestützte Fotogenerierung, zugeschnitten auf Ihren Seiteninhalt" prompt_suggestions_headline: "Prompt-Vorschläge" show_more_suggestions: "+%count% weitere" hide_suggestions: "Weniger anzeigen" @@ -443,6 +456,35 @@ 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: "Bilder passend zur Seite generieren" + back_to_editor: "Zurück zum Editor" + loading: "Wird geladen..." + generating_prompts: "Bildprompts werden generiert..." + regenerating_prompts: "Bildprompts werden neu generiert..." + generating_image: "Wird generiert..." + user_prompt_label: "Zusätzliche Anweisungen für den Stil der zu erstellenden Bilder" + system_prompt_label: "Hinter den Kulissen: Systemprompt für den Bildprompt-Generator" + regenerate_prompts: "Bildprompts neu generieren" + keep_prompt: "Prompt beibehalten" + regenerate_image: "Neu generieren" + upload_to_media_store: "Hochladen" + uploading_to_media_store: "Wird hochgeladen…" + uploaded_to_media_store: "Hochgeladen" + embed_into_page: "Generierte Bilder in Inhaltsseite einbetten" + embed_prefill_message: "Bitte binde die Bilder %fileNames% auf sinnvolle Weise in die Seite %pagePath% ein, so dass sie den Inhalt der Seite optimal ergänzen. Achte auf saubere Ausrichtung und Anordnung." + uploading_images: "Bilder werden hochgeladen, bitte warten..." + waiting_for_manifest: "Warten, bis Bilder im Content Delivery Network verfügbar sind..." + waiting_for_manifest_note: "Dies kann bis zu einer Minute dauern." + active_model: "%provider% — Prompts: %promptModel% · Bilder: %imageModel%" + resolution_label: "Auflösung:" + resolution_lores: "Niedrig" + resolution_hires: "Hoch" + 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..13a7f48 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -100,10 +100,18 @@ project: github_access_key: "GitHub access key" placeholder_github_token: "ghp_xxxxxxxxxxxx" github_access_key_help: "A personal access token so we can open the project and save changes." - llm_provider: "LLM Model Provider" - llm_api_key: "LLM API Key" + content_editing_llm_provider: "Content Editing — LLM Model Provider" + content_editing_api_key: "Content Editing — LLM API Key" placeholder_llm_api_key: "sk-..." - llm_api_key_help: "Your API key for the selected LLM provider." + content_editing_api_key_help: "Your API key for the selected LLM provider (used for content editing)." + photo_builder_llm_settings: "PhotoBuilder — Image Generation Settings" + photo_builder_llm_settings_help: "Choose how images are generated in PhotoBuilder." + photo_builder_mode_reuse: "Use Content Editing LLM settings for image generation" + photo_builder_mode_reuse_help: "PhotoBuilder will use the same OpenAI API key and provider configured above." + photo_builder_mode_dedicated: "Use dedicated LLM settings for image generation" + photo_builder_mode_dedicated_help: "Choose a specific provider and API key for PhotoBuilder image generation." + photo_builder_provider: "Image Generation Provider" + photo_builder_api_key: "Image Generation API Key" verifying_key: "Verifying key..." key_verified: "Key successfully verified" key_verification_failed: "Key verification failed" @@ -197,6 +205,11 @@ editor: session_finished: "This session is finished. You can review the history above." cannot_edit: "You cannot edit this session." preview_pages: "Preview pages" + preview_pages_description: "Preview and edit your generated pages" + edit_html: "Edit HTML" + preview: "Preview" + photo_builder_heading: "Generate images" + photo_builder_description: "AI-powered photo generation tailored to your page content" prompt_suggestions_headline: "Prompt suggestions" show_more_suggestions: "+%count% more" hide_suggestions: "Show less" @@ -443,6 +456,35 @@ remote_content_assets: browser_upload_success: "Upload complete. Refreshing asset list..." browser_upload_error: "Upload failed. Please try again." +# PhotoBuilder vertical +photo_builder: + title: "Generate images matching your page" + back_to_editor: "Back to editor" + loading: "Loading..." + generating_prompts: "Generating image prompts..." + regenerating_prompts: "Regenerating image prompts..." + generating_image: "Generating..." + user_prompt_label: "Additional image style instructions" + system_prompt_label: "Behind the scenes: System prompt for the image prompt generator" + regenerate_prompts: "Regenerate image prompts" + keep_prompt: "Keep prompt" + regenerate_image: "Regenerate" + upload_to_media_store: "Upload" + uploading_to_media_store: "Uploading…" + uploaded_to_media_store: "Uploaded" + embed_into_page: "Embed generated images into content page" + embed_prefill_message: "Please embed images %fileNames% into page %pagePath% in a useful and sensible way, so that they complement the page content optimally. Keep an eye on alignment and structure." + uploading_images: "Uploading images, please wait..." + waiting_for_manifest: "Waiting for images to become available on the content delivery network..." + waiting_for_manifest_note: "This can take up to one minute." + active_model: "%provider% — Prompts: %promptModel% · Images: %imageModel%" + resolution_label: "Resolution:" + resolution_lores: "Lo-res" + resolution_hires: "Hi-res" + 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"