Status note (kept for history). This is the original design plan; the shipped implementation diverged in two ways. (1) The editor is embedded as an iframe overlay inside Bloom's existing edit-tab WebView2, not a dedicated WebView2 window (so the
chrome.webview"WebView bridge" path described below was retired in favour of the iframe/postMessage path — seeservices/host/BloomHostBridge.ts). (2)BloomHostShellwas renamedBloomEmbeddedShell. For the current architecture read the header comments inBloomHostBridge.ts,App.tsx, and (Bloom side)AiImageEditorApi.cs, plus the "How Bloom hosts this editor" section of the README.
Make this app embeddable inside a Bloom WebView2 window, while keeping it runnable standalone for development and testing. When embedded, Bloom supplies the book's images, owns history and reference folders, owns API credentials, and ultimately swaps in the user's chosen replacement images when the window closes.
┌──────────────────────────────────────────────────────────┐
│ Standalone: App.tsx → StandaloneShell │
│ • API-key dialog, dev overlays, sample book images │
│ • IndexedDB persistence │
│ • No commit concept (or dev-only "log replacements") │
│ │ │
│ ▼ │
│ ImageToolsWorkspace (core — host-agnostic) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Bloom: index-bloom.tsx → BloomHostShell │
│ • BloomHostBridge (postMessage + HTTP) │
│ • Waits for `init`, then mounts core │
│ • Commit button, request-close handler │
│ • HTTP-backed persistence │
│ │ │
│ ▼ │
│ ImageToolsWorkspace (same core — host-agnostic) │
└──────────────────────────────────────────────────────────┘
The ImageToolsWorkspace core is genuinely host‑agnostic. It has no
notion of "Bloom mode" — no if (host) branches, no awareness of commit or
request-close. The core just renders the workspace, runs tools, and maintains
pair‑slot state. The shell decides what "done" means.
Two injection points only:
persistence— already exists. Standalone backs it with IndexedDB; Bloom shell backs it with an HTTP adapter that talks to Bloom's localhost server. The core sees the same interface either way.onReplacementsChange?: (map: Record<incomingId, HistoryEntry | null>) => void— fired whenever the user assigns or clears an outgoing slot. Standalone may ignore it (or use it for a dev log). Bloom shell uses it to track what to send on commit.
- API‑key entry dialog, env‑key handling, dev seed helpers, drag‑timing overlay (standalone shell only).
- Persistence selection:
createBrowserImageToolsPersistencein standalone,createBloomHostPersistence(bridge)in Bloom shell. - The
BloomHostBridgeitself (postMessage/HTTP adapter). - Lifecycle: handling
init(seed core's initial images + persistence config), the in‑app Commit button,request-closehandler, sendingcommit/cancel. - Standalone seed images (the four sample art styles) — passed into the core as initial Book Images strip content.
ImageToolsWorkspaceand everything it currently renders (tools panel, canvas, history strip, thumbnail strip).- All tool execution logic, OpenRouter client, art styles, etc.
- The Book Images strip, renamed from the current environment-strip model.
- Pair‑slot rendering on the Book Images strip, unconditionally. Every incoming image gets an outgoing slot underneath. Works the same in standalone and Bloom modes.
onReplacementsChangeprop (described above).incomingSlotIdon every history entry — the GUID of the incoming book image this generation descended from. Used today for "Use this" routing and the per‑slot history view we may build later.- "Use this" button in the result pane (always present when there's a current
result with an
incomingSlotId).
Per discussion: Option A — window.chrome.webview.postMessage + virtual host
file mapping. Bloom maps https://bloom-book.invalid/ to the book folder and
https://bloom-refs.invalid/ to the shared reference folder. Images are
referenced by URL; no base64 over the bridge.
const isBloomHosted = typeof window !== "undefined" && !!window.chrome?.webview;type |
Payload |
|---|---|
init |
{ book: { id, title }, bookImages: [{ id: guid, src: url, pageLabel?, width?, height? }], historyFolderUrl, referenceFolderUrl, references: [{ id, src, name? }], apiKey, openRouterUser?, httpBase: string, sessionToken: string } |
request-close |
{} — Bloom asks the app to commit/cancel (e.g. window X button). App responds with commit (RPC) or cancel. |
References are sent once in init and never change from outside while the app
is running (Bloom blocks other writers to that folder for the duration of the
session), so there's no references-changed message. The app maintains the
list in memory and updates it optimistically when its own save-reference /
delete-reference HTTP calls succeed.
type |
Payload |
|---|---|
ready |
{} — sent on mount; Bloom replies with init. |
cancel |
{} — user closed without committing. Nothing is lost; history persists in the per‑book folder for next time. |
log |
{ level, message } — surfaces app logs to Bloom's logger. |
All file writes/deletes go through Bloom's localhost server. Each request
carries X-Bloom-Session: <sessionToken> from init.
| Method | Path | Body |
|---|---|---|
| POST | /imageTools/history |
raw image bytes; ?filename=<name> |
| DELETE | /imageTools/history/{filename} |
— |
| POST | /imageTools/reference |
raw image bytes; ?filename=<name> |
| DELETE | /imageTools/reference/{filename} |
— |
HTTP already gives request/response, so these don't need a postMessage RPC wrapper.
A thin RPC wrapper is used only for calls that can fail and aren't HTTP —
currently just commit. Implementation: app sends
{ type, requestId, payload }, Bloom replies with
{ type: "ack", requestId, ok, error? }. Wrapped in a
host.request("commit", {...}): Promise<void> helper.
type |
Payload |
|---|---|
commit |
{ replacements: [{ incomingId: guid, newImageUrl: string }] } — Bloom applies, acks, then closes the window. |
WebView2 JS cannot write files directly. Decision: localhost POST against Bloom's existing HTTP server. Avoids the ~33% base64 overhead and lets Bloom do its own atomic-write / error-handling on the server side.
- App POSTs raw bytes to e.g.
http://localhost:PORT/imageTools/historyand/imageTools/reference(DELETE on the same paths for removal). - Bloom passes a per-session token in the
initmessage; the app includes it as a header (X-Bloom-Session: <token>) on every request so other localhost processes can't write into the book. - The mock Bloom host (dev mode) stubs uploads as no-op fetches that log to the console.
Each tile in the Book Images strip is a vertically stacked pair: incoming on
top, outgoing slot on the bottom. This is core behavior, present in both
standalone and Bloom modes — standalone exercises it for dev and testing,
Bloom uses the resulting map for commit.
┌─────────────┐
│ incoming │ ← original book image (read-only)
├─────────────┤
│ outgoing │ ← drop target; empty = "keep original"
└─────────────┘
- Drag from the history strip → drop onto a pair's outgoing slot.
- "Use this" button in the result pane → assigns to the outgoing slot of the pair whose incoming was the current edit target.
- Click an outgoing slot → clears it (revert to "keep original").
To make "Use this" route correctly even three generations into editing, every history entry carries the GUID of the incoming image whose pair it belongs to.
interface HistoryEntry {
id: string;
src: string;
// … existing fields …
incomingSlotId?: string; // NEW — guid from init.bookImages[].id
}Propagation rule: when a tool runs against a target image that is a history
entry, the result inherits that entry's incomingSlotId. When a tool runs
against an incoming image directly, the result's incomingSlotId = that
incoming's id. Tools that generate from scratch (no target) produce entries
with incomingSlotId = undefined; these don't auto‑route via "Use this" but
can still be dragged into any slot.
(UI for browsing per‑slot history is out of scope for v1 — we keep the flat history strip. The field is recorded now so we can add that view later without a data migration.)
ImageToolsPersistence (the existing interface) gains methods that the
standalone implementation backs with IndexedDB and the Bloom implementation
backs with host messages:
loadHistory()/saveHistory(entry)/deleteHistoryEntry(id)loadReferences()/saveReference(entry)/deleteReference(id)getApiKey()/setApiKey(key)— in Bloom mode,getApiKeyreturns the key from theinitmessage andsetApiKeyis a no‑op.
We may not need new methods at all if the current interface already covers
these (TBD on first pass through services/persistence/). The point is: the
core calls the same methods; only the implementation differs.
- User hits the window's close button → Bloom sends
request-close→ app immediately sendscancel. No confirmation dialog. The per‑book history folder retains everything, so reopening shows the same state. - User clicks the in‑app "Commit" button → app sends
commitwith the replacements map → Bloom applies, closes the window.
-
pnpm devcontinues to launch the standalone shell (current behavior). -
Add a concrete in-repo Bloom host harness for end-to-end coverage and local integration work. It should:
- Simulate
initwith fixed sample book image data. - Exercise the real bridge and shell contracts with deterministic local data.
- Expose stable test IDs for host actions and captured commit payloads.
This keeps Playwright coverage on a real harness instead of a mock-host flow.
- Simulate
- Refactor shell vs. core. Move API‑key dialog, env‑key handling, dev
overlays, and seed wiring out of
ImageToolsWorkspaceand into a newStandaloneShellconsumed byApp.tsx. The core should accept everything it needs via props — noimport.meta.envchecks inside it. - Rename the current environment-strip model to Book Images. Replace the
environmentstrip id/config/props withbookImagesnaming across the app, including persistence migration for old saved strip state. - Pair‑slot UI on the Book Images strip (core). Every tile becomes an
incoming/outgoing pair. Drag‑drop wiring reuses existing dnd infrastructure.
Add the
onReplacementsChangeprop. incomingSlotIdpropagation (core). Thread through tool execution result handling. Add the "Use this" button to the result pane (routes to the slot identified by the current result'sincomingSlotId).- Define
BloomHostBridge. A thin TypeScript module that ownspostMessage, an event emitter for incoming messages, typed senders, and the RPC correlation forcommit. No React. Lives inservices/host/. - Rename the host protocol payload to use
bookImages. Keep product language and bridge typing consistent by renaming the legacyimagesfield tobookImages. BloomHostPersistence. ImplementsImageToolsPersistenceagainst Bloom's localhost HTTP server (usinghttpBase+sessionTokenfrominit). Replaces IndexedDB for history and references; reads API key frominit.BloomHostShell. ConsumesBloomHostBridge, waits forinit, mountsImageToolsWorkspacewith the HTTP persistence and the initial book images, renders the Commit button, handlesrequest-close, sendscommit/cancel.- Bloom host harness. Add a real in-repo harness for local integration and Playwright coverage rather than a mock-host test flow.
- End‑to‑end tests. Add Playwright scenarios for harness init, Book Images rendering, Use this assignment, drag/drop replacement, request-close → cancel, and commit payload assertions.
- Binary file transfer: localhost POST to Bloom's HTTP server with a per‑session token header. No base64 over the postMessage bridge.
- RPC: thin request/response wrapper on postMessage used only for
commit. Everything else is either pure pub/sub (notifications, logs) or plain HTTP (file writes/deletes), both of which already have appropriate semantics. - References freshness: Bloom blocks external modification of the
reference folder while the app is open, so the list is sent once in
initand maintained in‑memory with optimistic updates on the app's own writes. No watcher, no refresh, noreferences-changedmessage.
This section plans the host side work in the Bloom Editor repo (symlinked at
BloomEditor/, real path d:\bloom.worktrees\AddAIImages) plus the
small companion changes needed in this repo to make the editor persist into a
Bloom‑owned folder. It supersedes the parts of the sections above that assume
book‑image sharing and commit — those are explicitly out of scope for v1.
In scope. Bloom launches the AI editor in a near‑full‑screen WebView2 window
from the image context menu, gives it a per‑book .ai-image-editor folder, and the
editor persists its history images, metadata state, and OpenRouter connection info
into that folder so a relaunch restores the previous session.
Out of scope (deferred). Sharing the book's images into the editor, the
incoming/outgoing pair‑slot UI, the "Commit"/commit RPC that sends replacements
back, references, and any modification of the book itself. The editor opens with an
empty Book Images strip and the user works entirely inside the editor.
| Topic | Decision |
|---|---|
| Packaging | The editor is a self‑contained app bundle — its own index.html + JS with React 19 / MUI 7 bundled in. Bloom loads it into a WebView2 as a standalone page; no React sharing with Bloom (which is React 17). |
| Folder I/O | New BloomServer HTTP endpoints scoped to the book's .ai-image-editor folder (GET/POST/DELETE files). The editor uses fetch. Reuses Bloom's existing API infra (RegisterEndpointHandler, RawPostData, ReplyWithFileContent). |
| API key | The editor owns the OpenRouter key. Bloom supplies only the folder; the editor writes connection info into .ai-image-editor/connection.json and reads it back on relaunch. Bloom never sees the key. |
| Dev linking | Fast loop = Bloom WebView2 points at the editor's Vite dev server (http://localhost:3000), mirroring how Bloom already loads its own React from localhost:5173. yarn link is used only to exercise the production asset‑copy path before publishing. See Dev & build workflow. |
This repo is React 19.2 + MUI 7; BloomBrowserUI is pinned to React
17.0.2 with a single node_modules. A WebView2 window is its own browser
document, so the editor page runs its own React 19 with zero contact with
Bloom's React 17. We therefore do not add the editor to Bloom's Vite entry
points or its bundleToViteModulePathMap; Bloom only needs a URL to navigate to.
Created on demand inside the current book folder
(BookSelection.CurrentSelection.FolderPath). Layout mirrors the split already used
by services/persistence/browserPersistence.ts (metadata separate from image bytes):
<book folder>/
.ai-image-editor/
state.json ← PersistedImageToolsState with history[].imageData = ""
connection.json ← { apiKey, authMethod, openRouterUser? } (editor-owned)
history/
<imageRecordId>.png ← decoded PNG bytes, one per history entry
state.jsonis the metadata‑only state (exactly whatbrowserPersistencewrites to its:metakey today — history entries keep all fields exceptimageData, which is blanked).- Each history image is a real PNG file (base64 decoded to bytes on write, re‑encoded to base64 on read), so the folder is human‑inspectable and portable with the book.
.ai-image-editorshould be excluded from Bloom's book‑bundling/upload and from the "doomed images" sweep inBookStorage(see step H7) so Bloom never ships or prunes it.
A pared‑down version of the protocol above. The bridge already exists in services/host/BloomHostBridge.ts; v1 trims the payload and replaces the localStorage‑backed state methods with HTTP.
{
book: { id: string; title: string };
folderUrl?: string; // optional convenience; reads also work via httpBase
httpBase: string; // e.g. "http://localhost:8089/bloom/api/aiImageEditor"
sessionToken: string; // sent as X-Bloom-Session on every file request
bookImages: []; // empty in v1
references: []; // empty in v1
apiKey: null; // editor owns the key in v1
}The existing BloomHostInitPayload interface already makes the v1‑irrelevant fields
optional/empty‑able, so no breaking type change is required — just stop populating
them on the host side.
All scoped to <book>/.ai-image-editor, all require X-Bloom-Session: <sessionToken>.
Filenames are validated server‑side (no traversal, allowlisted to
state.json, connection.json, history/<id>.png).
| Method | Path (under httpBase) |
Body / Result |
|---|---|---|
| GET | /file?name=state.json |
JSON text (404 if absent → editor starts fresh) |
| POST | /file?name=state.json |
raw JSON bytes |
| GET | /file?name=connection.json |
JSON text (404 if absent) |
| POST | /file?name=connection.json |
raw JSON bytes |
| GET | /file?name=history/<id>.png |
image bytes |
| POST | /file?name=history/<id>.png |
raw image bytes |
| DELETE | /file?name=history/<id>.png |
— |
(Single generic /file endpoint with a name query keeps the C# handler small and
the allowlist in one place; we can split later if useful.)
- App → Host:
ready,cancel,log. - Host → App:
init,request-close. - Dropped in v1:
commitRPC (and itsack). The Cancel/close path just sendscancel; nothing is committed because nothing is shared back yet.
These are the companion changes that the "implemented" component side still needs so
that persistence lands in the Bloom folder instead of localStorage.
- C1 — HTTP‑backed bridge. In
services/host/BloomHostBridge.ts, the WebView
bridge currently stores state in
localStorage(loadLocalState/saveLocalState/clearLocalState). That cannot persist across launches because WebView2 uses a fresh temp user‑data folder each time. CapturehttpBase+sessionTokenfrominitand back the file operations withfetch. Expose granular file ops the persistence layer can call (e.g.getFile(name),putFile(name, bytes|json),deleteFile(name)), keepingloadState/saveState/clearStateas thin wrappers overstate.json. - C2 — Folder‑splitting Bloom persistence. Update
services/persistence/bloomHostPersistence.ts
to mirror
browserPersistence's split: onsave, writestate.jsonwithimageDatablanked and POST each changed history image tohistory/<id>.png(base64 → bytes); onload, GETstate.jsonthen hydrate each entry'simageDataby GETting its PNG (bytes → base64); track last‑saved hashes to avoid re‑POSTing unchanged images and to DELETE removed ones — the diff logic already exists inbrowserPersistenceand can be factored into a shared helper. - C3 — Editor‑owned connection info. In Bloom mode, route the editor's API‑key
get/set to
connection.jsonvia the bridge (instead of the current "no‑op in Bloom mode" behavior). The editor's existing settings/API‑key dialog stays; only its persistence target changes. On launch, ifconnection.jsonhas a key, the editor authenticates without prompting. - C4 — A stable hosted entry/build. Confirm the standalone build (
vp build→ demo output, theindex.htmlapp — not the tsup library output) is what Bloom loads. Detection in App.tsx already branches toBloomHostShellwhenwindow.chrome.webviewis present, so the same built app works both standalone‑in‑browser and hosted‑in‑WebView2. The published npm package should include this standalone app build (e.g. adist-app/directory) infiles, in addition to the librarydist/. - C5 — CORS in dev. When loaded from the Vite dev server (
:3000) the editor'sfetchcalls hit Bloom's server (:8089) cross‑origin. The host endpoints must sendAccess-Control-Allow-Originfor the dev origin and answer theOPTIONSpreflight allowing theX-Bloom-Sessionheader. (Production is same‑origin — see below — so this only matters for the dev loop.)
References below are to files surfaced during exploration; treat line numbers as approximate.
- H1 — Menu item. Add "Edit with AI…" to the image context menu in
BloomEditor/src/BloomBrowserUI/bookEdit/js/CanvasElementContextControls.tsx(addImageMenuOptions(), alongside "Choose image from your computer…"). On click,postData("editView/launchAiImageEditor", { imageId, imageSrc })(or a dedicatedaiImageEditor/launchendpoint — see H2). v1 ignores the specific image; the menu item is just the entry point. - H2 — New C# API class
AiImageEditorApi. A new controller underBloomEditor/src/BloomExe/web/controllers/registered withBloomApiHandler(followBookMetadataApi.cs). Responsibilities:aiImageEditor/launch(POST, UI thread): ensure the folder exists, then open the dialog (H4).aiImageEditor/file(GET/POST/DELETE): the folder file endpoints. ValidateX-Bloom-Session, allowlistname, resolve underCurrentBook.FolderPath/.ai-image-editor, userequest.RawPostDatafor writes andrequest.ReplyWithImage/ReplyWithFileContent(or a JSON reply) for reads.- Holds the per‑session token minted at launch.
- H3 — Folder creation. On launch, create
Path.Combine(CurrentBook.FolderPath, ".ai-image-editor")and itshistory/subfolder if absent (RobustIO/Directory.CreateDirectory). - H4 — Near‑full‑screen WebView2 window. Reuse the existing dialog plumbing
(
ReactDialog→ReactControl→WebView2Browser, inBloomEditor/src/BloomExe/MiscUI/ReactDialog.csandweb/ReactControl.cs) but point the WebView2 at the editor's URL rather than a Bloom Vite bundle. Because the editor is self‑contained, the cleanest path is a thin custom form that hosts aWebView2Browserand navigates to the editor URL (H5), sized to ~95% of the work area / maximized. The existingbundleToViteModulePathMapis not touched. - H5 — Editor URL resolution (dev vs prod).
- Dev: navigate to
http://localhost:3000/?...(the editor's running Vite dev server) when a dev flag/env is set. Gives HMR on editor edits. - Prod: serve the editor's built app from Bloom's own server so it is
same‑origin with
/bloom/api/...(no CORS). Copy the package'sdist-app/intooutput/browser/aiImageEditor/at build time and navigate to…/bloom/aiImageEditor/index.html.
- Dev: navigate to
- H6 — init / messaging wiring. After the WebView2 signals it's ready (the editor
posts
readyviachrome.webview.postMessage), C# handlesWebMessageReceivedand posts back the v1initpayload (book id/title,httpBasepointing at theaiImageEditorAPI root, freshly mintedsessionToken). Handlecancelandlog. On the window's close (X) button, sendrequest-closeand let the editor replycancel, then close. (WebView2Browseralready exposesWebMessageReceivedandRunJavascriptAsync/post helpers.) - H7 — Exclude the folder from book operations. Ensure
.ai-image-editoris ignored byBookStorage's unused‑image cleanup and by book upload/bundling so it is neither pruned nor shipped. Add it to the relevant skip lists.
With the self‑contained decision, the fast inner loop does not depend on
yarn link vs Vite alias — those matter only when one project bundles the other,
which Bloom no longer does. Instead:
- Fast loop (recommended default). Run this repo's
vp dev(editor onhttp://localhost:3000). Run Bloom with a dev flag that makes H5 navigate the WebView2 tolocalhost:3000. Editor code edits hot‑reload inside Bloom's WebView2 instantly; only C# changes require a Bloom rebuild. This is the analogue of Bloom's ownlocalhost:5173React dev mode, so it fits existing developer muscle memory. No copy step, no linking. - Production‑path check (before publishing). To exercise the same‑origin served
build:
yarn linkthis package fromBloomBrowserUI(matches how Bloom linksbloom-player), build the editor app, and have Bloom's build copydist-app/intooutput/browser/aiImageEditor/. Use this to validate H5‑prod and CORS‑free operation.file:and Vite alias are viable alternatives but unnecessary given the dev‑server loop above. BloomBrowserUI/package.jsondependency. Addbloom-ai-image-toolsas a dependency so the production copy step has a resolvable source. During dev this can be theyarn linksymlink; post‑publish it becomes a normal version range.
Component side (this repo) and host side (Bloom) can largely proceed in parallel; the contract in Communication contract for v1 is the seam.
- C1/C2/C3 — HTTP‑backed bridge, folder‑splitting persistence, connection.json.
Land behind the existing
window.chrome.webviewdetection; verify against the in‑repo harness with a fake HTTP layer first. - C4 — produce and package the standalone app build (
dist-app/); confirm the hosted entry works when opened in a real WebView2. - H2/H3 —
AiImageEditorApiwith folder creation + the/fileendpoints; unit test path validation and session‑token enforcement. - H4/H5/H6 — the WebView2 window, dev/prod URL resolution, and init/messaging.
- H1 — the context‑menu item that calls
launch. - C5/H5‑prod — CORS for the dev origin; the prod same‑origin asset copy.
- H7 — exclude
.ai-image-editorfrom cleanup/upload. - End‑to‑end smoke: open editor from menu → generate an image → close → reopen → history, state, and API key all restored from the folder.
- Window chrome: borderless maximized form vs. a titled resizable dialog? (H4 — default to maximized with a close button; cosmetic, decide during implementation.)
- Multiple books / stale sessions: the session token is per‑launch; confirm Bloom rejects file requests after the window closes (token invalidated).
- Concurrency: assume a single editor window at a time (Bloom blocks reopening while one is open). Revisit if not guaranteed.
- v2 hooks: book‑image sharing, pair‑slot UI, and
commitreattach at the seams left above (emptybookImages/references, nocommit).