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 @@
You are a friendly AI assistant that helps the user to generate {{ imageCount }} 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:
+
+[Content of your page at {{ pagePath }}]
+
+Think about what each of the {{ imageCount }} images should show in order to optimally fit the narrative of the web page content.
+
+Important: The language used for the prompts must match the language of the user interface of the web page!
+
+For each image, call the deliver_image_prompt tool with:
+- A detailed, descriptive prompt suitable for an AI image generation model
+- 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")
+ {# PhotoBuilder LLM Settings #}
+ {% if keysVisible is not defined or keysVisible %}
+ {% set currentPhotoBuilderMode = (project and project.photoBuilderLlmModelProvider is not null) ? 'dedicated' : 'reuse' %}
+
+ {% 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 #}
+