@molecule/app-ai-image-generator-default

Provider bond · ai-image-generator · App (browser) · v1.0.2 · Apache-2.0

Default HTTP image generator provider implementation

npm install @molecule/app-ai-image-generator-default

npm · Source on GitHub · Implements @molecule/app-ai-image-generator

How it works

@molecule/app-ai-image-generator-default is a provider bond on the app (browser) side: it implements the ai-image-generator core interface (@molecule/app-ai-image-generator) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

import { setProvider } from '@molecule/app-ai-image-generator'
import { provider } from '@molecule/app-ai-image-generator-default'

setProvider(provider) // custom baseUrl/headers: setProvider(createProvider({...}))

Works with: @molecule/app-i18n

Reference

Default HTTP image generator provider for molecule.dev — generates images through YOUR backend endpoint (JSON or SSE), with history load/delete.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { setProvider } from '@molecule/app-ai-image-generator'
import { provider } from '@molecule/app-ai-image-generator-default'

setProvider(provider) // custom baseUrl/headers: setProvider(createProvider({...}))

Type

provider

Installation

npm install @molecule/app-ai-image-generator-default @molecule/app-ai-image-generator @molecule/app-i18n

API

Interfaces

HttpImageGeneratorConfig

Configuration for the default HTTP image generator provider.

interface HttpImageGeneratorConfig {
  /** Base URL for API requests. Defaults to `''` (same origin). */
  baseUrl?: string
  /** Custom headers to include in requests. */
  headers?: Record<string, string>
}

Classes

HttpImageGeneratorProvider

HTTP-based implementation of AIImageGeneratorProvider.

Sends generation requests via POST, reads progress via SSE or JSON responses, and supports history loading and image deletion.

class HttpImageGeneratorProvider implements AIImageGeneratorProvider {
  readonly name = 'default'
  private config: HttpImageGeneratorConfig
  private abortController: AbortController | null = null

  /**
   * Creates a new HTTP image generator provider.
   *
   * @param config - HTTP-specific configuration (base URL, headers).
   */
  constructor(config: HttpImageGeneratorConfig = {}) {
    this.config = config
  }

  /**
   * Generates images by sending a POST request to the backend.
   * Handles both SSE streaming responses (with progress events) and
   * plain JSON responses (single batch of images).
   *
   * @param request - The generation parameters.
   * @param config - API endpoint and model configuration.
   * @param onEvent - Callback for generation lifecycle events.
   * @returns The array of generated images.
   */
  async generate(
    request: ImageGenerationRequest,
    config: ImageGenerationConfig,
    onEvent: ImageGenerationEventHandler,
  ): Promise<GeneratedImage[]> {
    this.abortController?.abort()
    const controller = new AbortController()
    this.abortController = controller
    const { signal } = controller

    const url = `${this.config.baseUrl ?? ''}${config.endpoint}`
    const { width, height } = parseSize(request.size)

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...this.config.headers,
        },
        body: JSON.stringify({
          prompt: request.prompt,
          ...(request.negativePrompt ? { negativePrompt: request.negativePrompt } : {}),
          ...(request.size ? { size: request.size } : {}),
          ...(request.count != null ? { count: request.count } : {}),
          ...(request.format ? { format: request.format } : {}),
          ...(request.quality ? { quality: request.quality } : {}),
          ...(request.style ? { style: request.style } : {}),
          model: request.model ?? config.model,
        }),
        signal,
      })

      if (!response.ok) {
        const text = await response
          .text()
          .catch(() =>
            t('imageGenerator.error.unknown', undefined, { defaultValue: 'Unknown error' }),
          )
        let errorMessage: string | undefined
        try {
          const body = JSON.parse(text)
          if (typeof body.error === 'string') errorMessage = body.error
        } catch (_error) {
          // Not JSON — use raw text as-is; parse failure is expected for plain-text error bodies
        }
        onEvent({
          type: 'error',
          message:
            errorMessage ??
            t(
              'imageGenerator.error.httpError',
              { status: response.status, text },
              { defaultValue: 'HTTP {{status}}: {{text}}' },
            ),
        })
        return []
      }

      const contentType = response.headers.get('content-type') ?? ''

      // SSE streaming response — parse progress and image events
      if (contentType.includes('text/event-stream')) {
        return await this.handleStreamResponse(response, onEvent, controller, signal)
      }

      // Plain JSON response — single batch of images
      return await this.handleJsonResponse(response, onEvent, width, height)
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') {
        return []
      }
      const message =
        err instanceof Error
          ? err.message
          : t('imageGenerator.error.generateFailed', undefined, {
              defaultValue: 'Image generation failed',
            })
      onEvent({ type: 'error', message })
      return []
    } finally {
      if (this.abortController === controller) {
        this.abortController = null
      }
    }
  }

  /**
   * Handles an SSE streaming response, parsing progress and image events.
   *
   * @param response - The fetch Response with an SSE body.
   * @param onEvent - Event callback.
   * @param controller - The AbortController for this request.
   * @param signal - The abort signal.
   * @returns The collected generated images.
   */
  private async handleStreamResponse(
    response: Response,
    onEvent: ImageGenerationEventHandler,
    controller: AbortController,
    signal: AbortSignal,
  ): Promise<GeneratedImage[]> {
    if (!response.body) {
      onEvent({
        type: 'error',
        message: t('imageGenerator.error.noResponseBody', undefined, {
          defaultValue: 'No response body',
        }),
      })
      return []
    }

    const reader = response.body.getReader()
    const decoder = new TextDecoder()
    let buffer = ''
    const images: GeneratedImage[] = []

    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n')
      buffer = lines.pop() ?? ''

      for (const line of lines) {
        if (!line.startsWith('data: ')) continue
        const data = line.slice(6).trim()
        if (!data) continue

        try {
          const event = JSON.parse(data) as ImageGenerationEvent
          if (event.type === 'image') {
            images.push(event.image)
          }
          onEvent(event)
        } catch (_error) {
          // Skip malformed SSE data — a single bad line should not abort the stream
        }
      }

      // Yield to the main thread between chunks
      if (!signal.aborted) {
        await new Promise((r) => setTimeout(r, 0))
      }
    }

    // Process any remaining buffer
    if (buffer.startsWith('data: ')) {
      const data = buffer.slice(6).trim()
      if (data) {
        try {
          const event = JSON.parse(data) as ImageGenerationEvent
          if (event.type === 'image') {
            images.push(event.image)
          }
          onEvent(event)
        } catch (_error) {
          // Skip malformed data in the trailing buffer — safe to discard incomplete/invalid events
        }
      }
    }

    // Only clear if this call's controller is still the active one
    if (this.abortController === controller) {
      this.abortController = null
    }

    return images
  }

  /**
   * Handles a plain JSON response containing generated images.
   *
   * @param response - The fetch Response with a JSON body.
   * @param onEvent - Event callback.
   * @param width - Expected image width.
   * @param height - Expected image height.
   * @returns The generated images.
   */
  private async handleJsonResponse(
    response: Response,
    onEvent: ImageGenerationEventHandler,
    width: number,
    height: number,
  ): Promise<GeneratedImage[]> {
    const body = (await response.json()) as {
      images?: Record<string, unknown>[]
    }

    const rawImages = body.images ?? []
    const images: GeneratedImage[] = rawImages.map((raw, i) => {
      const img = normalizeImage(raw, i)
      // Fall back to request dimensions if the server didn't include them
      if (!raw.width) img.width = width
      if (!raw.height) img.height = height
      return img
    })

    onEvent({ type: 'started' })
    for (const image of images) {
      onEvent({ type: 'image', image })
    }
    onEvent({ type: 'done', images })

    return images
  }

  /**
   * Aborts the current in-flight generation request, if any.
   */
  abort(): void {
    this.abortController?.abort()
    this.abortController = null
  }

  /**
   * Loads previously generated images from the server via GET request.
   *
   * @param config - API endpoint configuration.
   * @returns An array of generated images, or an empty array on failure.
   */
  async loadHistory(config: ImageGenerationConfig): Promise<GeneratedImage[]> {
    const url = `${this.config.baseUrl ?? ''}${config.endpoint}`

    try {
      const response = await fetch(url, {
        headers: this.config.headers,
      })

      if (!response.ok) return []

      const data = (await response.json()) as {
        images?: Record<string, unknown>[]
// … truncated — the full declaration is in node_modules/@molecule/app-ai-image-generator-default/dist/provider.d.ts

Functions

createProvider(config)

Creates an HttpImageGeneratorProvider instance.

function createProvider(config?: HttpImageGeneratorConfig): HttpImageGeneratorProvider
  • config — HTTP-specific configuration (base URL, headers).

Returns: A configured HttpImageGeneratorProvider.

Constants

provider

Pre-instantiated provider singleton.

const provider: HttpImageGeneratorProvider

Core Interface

Implements @molecule/app-ai-image-generator interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/app-ai-image-generator'
import { provider } from '@molecule/app-ai-image-generator-default'

export function setupAiImageGeneratorDefault(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-ai-image-generator ^1.0.1
  • @molecule/app-i18n ^1.0.1

Runtime Dependencies

  • @molecule/app-ai-image-generator
  • @molecule/app-i18n

Talks to YOUR backend at config.endpoint — no vendor key in the browser. Server contract: POST { prompt, negativePrompt?, size?, count?, format?, quality?, style?, model? }; reply EITHER as plain JSON { images: [{ id, url, prompt, width?, height?, createdAt? }] } OR as an SSE stream (Content-Type: text/event-stream) of data: <ImageGenerationEvent JSON> lines (started / progress / image / done / error) — the bond auto-detects by content type. GET the same endpoint → { images } for history (fails open to []); DELETE ${endpoint}/${id} removes one image. generate() NEVER rejects: on any failure it emits an error event and resolves [] — drive error UI from the event callback, not try/catch.

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • Entering a prompt and generating renders the resulting GeneratedImage(s) as real pixels on screen: the image / done events deliver objects whose url loads to a visible picture, not a broken or placeholder tile.
  • A generating indicator shows while the request is in flight — driven by the started / progress (progress.percent) events — and clears when done fires and generate() resolves; it never spins forever.
  • A generation failure surfaces visibly: the error event's message (provider failure, or a moderation-rejected prompt) renders as a message in the UI, never a silent no-op or a stuck spinner.
  • When the request sets count > 1 (bounded 1–10), ALL requested images render — the done event's images array length matches what was asked, not just the first one.
  • The request's size preset (e.g. '1024x1024' vs '1792x1024') is honored in the output: the rendered image's width / height match the requested dimensions rather than a default square.
  • The generated image is usable downstream as the app wires it (insert / download / select), carrying the real GeneratedImage data (id, url); because url may be temporary, an image kept in a gallery still loads after a full reload — proving the app persisted the bytes, not a dead upstream link.
  • Generations are scoped to the requesting user: loadHistory / the gallery returns only that user's own images, and a moderation-rejected prompt shows the rejection rather than a blank tile.