Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3af9d32
Add PhotoBuilder vertical: domain layer, infrastructure adapters, and…
manuelkiessling Feb 10, 2026
0de860c
Add PhotoBuilder presentation layer: controller, template, Stimulus, …
manuelkiessling Feb 10, 2026
dc8eb74
Add Content Editor integration, database migration, and frontend test…
manuelkiessling Feb 10, 2026
ce04d6a
Fix PhotoBuilder runtime issues: Twig URL generation, OpenAI API para…
manuelkiessling Feb 10, 2026
a1e2461
PhotoBuilder: styleguide UI, shared Remote Assets partial, upload fee…
manuelkiessling Feb 10, 2026
6353558
wip
manuelkiessling Feb 10, 2026
c53621f
PhotoBuilder: upload feedback, regenerate prompts UX, language switch…
manuelkiessling Feb 10, 2026
5490fd6
prompt lang matches page language
manuelkiessling Feb 10, 2026
e704774
Embed CTA: upload images before navigating to editor
manuelkiessling Feb 11, 2026
2dc6ad2
PhotoBuilder: use actual S3 filenames in embed message, respect Keep …
manuelkiessling Feb 11, 2026
c684e56
Fix parent-to-child event dispatch for prompt regeneration UX
manuelkiessling Feb 11, 2026
efa44d8
Cleanups
manuelkiessling Feb 11, 2026
084a9e2
Add Google Gemini support for PhotoBuilder with hierarchical LLM prov…
manuelkiessling Feb 11, 2026
4cfeb59
Fix LLM key verification for PhotoBuilder dedicated provider
manuelkiessling Feb 11, 2026
7b5c868
Add lo-res/hi-res resolution toggle for PhotoBuilder (Google Gemini o…
manuelkiessling Feb 11, 2026
8e00446
Scale messenger consumer to 5 replicas for parallel message processing
manuelkiessling Feb 11, 2026
8cf601b
Wait for manifest availability before redirecting to content editor
manuelkiessling Feb 11, 2026
108a36d
Switch Google image prompt generation to gemini-3-flash-preview
manuelkiessling Feb 11, 2026
e36cef2
Fix Gemini 3 function call detection for image prompt generation
manuelkiessling Feb 11, 2026
8f4721e
Fixed remote asset browser sidebar template location; removed widget …
manuelkiessling Feb 11, 2026
e3cfd15
PhotoBuilder: upload CTA feedback, stacked action buttons
manuelkiessling Feb 11, 2026
dda8872
PhotoBuilder: preserve user edits in Additional image style instructi…
manuelkiessling Feb 11, 2026
1310430
WIP: Cleanups
manuelkiessling Feb 11, 2026
c29ebf5
Fix child button state desync when parent generating state transitions
manuelkiessling Feb 11, 2026
6c852c2
Fix stale cached image after regeneration by adding timestamp cache-b…
manuelkiessling Feb 11, 2026
8a351b8
Fix prompt stuck at "Generating..." when image completes before next …
manuelkiessling Feb 11, 2026
c151b7f
Improve Content Editor preview section and PhotoBuilder UI polish
manuelkiessling Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
Expand Down
4 changes: 4 additions & 0 deletions assets/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
2 changes: 2 additions & 0 deletions config/packages/asset_mapper.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions config/packages/doctrine.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions config/packages/twig.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 88 additions & 2 deletions docs/frontendbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div {{ stimulus_controller('photo-builder', { ... })
|stimulus_action('photo-builder', 'handlePromptEdited', 'photo-image:promptEdited')
|stimulus_action('photo-builder', 'handleRegenerate', 'photo-image:regenerateRequested')
|stimulus_action('photo-builder', 'handleUpload', 'photo-image:uploadRequested') }}>
```

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)
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
<div {{ stimulus_controller('child-ctrl')
|stimulus_action('child-ctrl', 'doSomething', 'my-controller:doSomething') }}>
```

This means the `data-action` is on the **child** `<div>`, so it only captures `my-controller:doSomething` events that fire **on or below** that `<div>`.

### 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.
Expand Down
144 changes: 144 additions & 0 deletions docs/llm-usage-book.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions docs/vertical-wiring.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ flowchart LR
direction TB
CBCE["ChatBasedContentEditor"]
LLM["LlmContentEditor"]
PB["PhotoBuilder"]
WSM["WorkspaceMgmt"]
WST["WorkspaceTooling"]
ORG["Organization"]
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand All @@ -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**.
Loading