diff --git a/Classes/Ai.php b/Classes/Ai.php index 4d99ffa..8128993 100644 --- a/Classes/Ai.php +++ b/Classes/Ai.php @@ -14,6 +14,7 @@ use B13\Aim\Capability\ConversationCapableInterface; use B13\Aim\Capability\EmbeddingCapableInterface; +use B13\Aim\Capability\ImageGenerationCapableInterface; use B13\Aim\Capability\TextGenerationCapableInterface; use B13\Aim\Capability\TranslationCapableInterface; use B13\Aim\Capability\VisionCapableInterface; @@ -23,6 +24,7 @@ use B13\Aim\Request\AiRequestInterface; use B13\Aim\Request\ConversationRequest; use B13\Aim\Request\EmbeddingRequest; +use B13\Aim\Request\ImageGenerationRequest; use B13\Aim\Request\Message\AbstractMessage; use B13\Aim\Request\TextGenerationRequest; use B13\Aim\Request\TranslationRequest; @@ -246,6 +248,40 @@ public function embed( return $this->dispatch($request, EmbeddingCapableInterface::class); } + /** + * Generate one or more images from a prompt. + * + * Pass a reference image (e.g. an existing brand/header image) via + * $referenceImageData/$referenceMimeType to guide style and composition + * (image-to-image) instead of generating from the prompt alone. + * + * @param array $options Provider-specific options passed through as-is + * (e.g. ['size' => '1536x1024', 'quality' => 'high', 'background' => 'transparent']). + */ + public function generateImage( + string $prompt, + string $referenceImageData = '', + string $referenceMimeType = '', + array $options = [], + int $count = 1, + string $extensionKey = '', + string $user = '', + string $provider = '', + ): TextResponse { + $resolvedProvider = $this->resolve(ImageGenerationCapableInterface::class, $provider); + $request = new ImageGenerationRequest( + configuration: $resolvedProvider->configuration, + prompt: $prompt, + referenceImageData: $referenceImageData, + referenceMimeType: $referenceMimeType, + options: $options, + count: $count, + user: $user, + metadata: $this->buildMetadata($extensionKey), + ); + return $this->dispatch($request, ImageGenerationCapableInterface::class); + } + /** * Start a fluent request builder for advanced use cases. */ diff --git a/Classes/AiRequestBuilder.php b/Classes/AiRequestBuilder.php index 6ae560a..7cfba42 100644 --- a/Classes/AiRequestBuilder.php +++ b/Classes/AiRequestBuilder.php @@ -12,12 +12,14 @@ namespace B13\Aim; +use B13\Aim\Capability\ImageGenerationCapableInterface; use B13\Aim\Capability\TextGenerationCapableInterface; use B13\Aim\Capability\TranslationCapableInterface; use B13\Aim\Capability\VisionCapableInterface; use B13\Aim\Middleware\AiMiddlewarePipeline; use B13\Aim\Provider\ProviderResolver; use B13\Aim\Provider\ResolvedProvider; +use B13\Aim\Request\ImageGenerationRequest; use B13\Aim\Request\ResponseFormat; use B13\Aim\Request\TextGenerationRequest; use B13\Aim\Request\TranslationRequest; @@ -59,6 +61,12 @@ final class AiRequestBuilder private string $sourceLanguage = ''; private string $targetLanguage = ''; + // Image generation-specific + private string $referenceImageData = ''; + private string $referenceMimeType = ''; + private array $imageOptions = []; + private int $imageCount = 1; + public function __construct( private readonly ProviderResolver $providerResolver, private readonly AiMiddlewarePipeline $pipeline, @@ -88,6 +96,44 @@ public function translate(string $text, string $sourceLanguage, string $targetLa return $this; } + /** + * Generate an image. Use prompt() to set the description. + */ + public function image(): self + { + $this->type = 'imageGeneration'; + return $this; + } + + /** + * Guide image generation with a reference image (image-to-image / style transfer). + */ + public function referenceImage(string $imageData, string $mimeType): self + { + $this->referenceImageData = $imageData; + $this->referenceMimeType = $mimeType; + return $this; + } + + /** + * Provider-specific options passed through as-is, merged into whatever's + * already set (e.g. ->options(['size' => '1024x1024', 'quality' => 'high'])). + */ + public function options(array $options): self + { + $this->imageOptions = [...$this->imageOptions, ...$options]; + return $this; + } + + /** + * Number of images to generate in one request. + */ + public function count(int $count): self + { + $this->imageCount = $count; + return $this; + } + public function prompt(string $prompt): self { $this->prompt = $prompt; @@ -157,13 +203,34 @@ public function send(): TextResponse 'vision' => $this->sendVision($metadata), 'text' => $this->sendText($metadata), 'translation' => $this->sendTranslation($metadata), + 'imageGeneration' => $this->sendImageGeneration($metadata), default => throw new \LogicException( - 'No request type set. Call vision(), text(), or translate() before send().', + 'No request type set. Call vision(), text(), translate(), or image() before send().', 1773874300, ), }; } + private function sendImageGeneration(array $metadata): TextResponse + { + $capabilityClass = ImageGenerationCapableInterface::class; + $resolvedProvider = $this->resolve($capabilityClass); + $request = new ImageGenerationRequest( + configuration: $resolvedProvider->configuration, + prompt: $this->prompt, + referenceImageData: $this->referenceImageData, + referenceMimeType: $this->referenceMimeType, + options: $this->imageOptions, + count: $this->imageCount, + user: $this->user, + metadata: $metadata, + ); + return $this->pipeline->dispatchWithFallback( + $request, + $this->providerResolver->buildFallbackChain($capabilityClass), + ); + } + private function sendVision(array $metadata): TextResponse { $capabilityClass = VisionCapableInterface::class; diff --git a/Classes/Capability/ImageGenerationCapableInterface.php b/Classes/Capability/ImageGenerationCapableInterface.php new file mode 100644 index 0000000..c01d7ed --- /dev/null +++ b/Classes/Capability/ImageGenerationCapableInterface.php @@ -0,0 +1,21 @@ +providerRegistry->getProvider($configuration->providerIdentifier); $provider = $manifest->getInstance(); - // Build a minimal probe request to verify connectivity. + // Build a minimal probe request to verify connectivity. The configured + // MODEL, not just the adapter class, determines which probe is valid — + // a SymfonyAiPlatformAdapter implements every capability interface + // regardless of the specific model configured, so e.g. an embeddings-only + // model would otherwise get sent a conversational prompt it can't handle. // Prefer conversation over text generation since reasoning models // (o-series) work more reliably with the conversation API. // Use 256 max tokens to accommodate reasoning overhead. $start = hrtime(true); try { - if ($provider instanceof ConversationCapableInterface) { + if ($provider instanceof ConversationCapableInterface + && $manifest->hasModelCapability($configuration->model, ConversationCapableInterface::class) + ) { $probeRequest = new ConversationRequest( configuration: $configuration, messages: [new UserMessage('Respond with the single word: hello')], maxTokens: 256, ); $response = $provider->processConversationRequest($probeRequest); - } elseif ($provider instanceof TextGenerationCapableInterface) { + } elseif ($provider instanceof TextGenerationCapableInterface + && $manifest->hasModelCapability($configuration->model, TextGenerationCapableInterface::class) + ) { $probeRequest = new TextGenerationRequest( configuration: $configuration, prompt: 'Respond with the single word: hello', maxTokens: 256, ); $response = $provider->processTextGenerationRequest($probeRequest); + } elseif ($provider instanceof EmbeddingCapableInterface + && $manifest->hasModelCapability($configuration->model, EmbeddingCapableInterface::class) + ) { + $probeRequest = new EmbeddingRequest( + configuration: $configuration, + input: ['connection test'], + ); + $response = $provider->processEmbeddingRequest($probeRequest); + } elseif ($provider instanceof ImageGenerationCapableInterface + && $manifest->hasModelCapability($configuration->model, ImageGenerationCapableInterface::class) + ) { + // Unlike the other probes, this generates a real (billable) image — + // there's no free-tier "ping" for image generation endpoints. + $probeRequest = new ImageGenerationRequest( + configuration: $configuration, + prompt: 'a single red circle on a white background', + ); + $response = $provider->processImageGenerationRequest($probeRequest); } else { - return new JsonResponse(['ok' => false, 'message' => 'Provider does not support text generation or conversation for verification']); + return new JsonResponse(['ok' => false, 'message' => 'Model "' . $configuration->model . '" does not support a known verification probe (conversation, text generation, embeddings, or image generation)']); } } catch (\Throwable $e) { $result = [ diff --git a/Classes/Controller/RequestLogController.php b/Classes/Controller/RequestLogController.php index f075fe6..0124a4a 100644 --- a/Classes/Controller/RequestLogController.php +++ b/Classes/Controller/RequestLogController.php @@ -87,14 +87,7 @@ public function logAction(ServerRequestInterface $request): ResponseInterface $statistics['pending_grades'] = $this->logRepository->countPendingGradesOlderThan(3600); // Build pagination base URL with demand filters (append &page=N in template) - $paginationBaseParams = [ - 'orderField' => $demand->getOrderField(), - 'orderDirection' => $demand->getOrderDirection(), - ]; - foreach ($demand->getParameters() as $key => $value) { - $paginationBaseParams['demand[' . $key . ']'] = $value; - } - $paginationBaseUrl = (string)$this->uriBuilder->buildUriFromRoute('aim_request_log', $paginationBaseParams); + $paginationBaseUrl = (string)$this->uriBuilder->buildUriFromRoute('aim_request_log', $this->demandToRouteParams($demand)); // Resolve user_id -> username for display $userIds = array_unique(array_filter(array_map( @@ -105,6 +98,10 @@ public function logAction(ServerRequestInterface $request): ResponseInterface return $view->assignMultiple([ 'demand' => $demand, + // Filters are submitted via POST (Filters.html), so they never show up + // in the request's own URI/query string — it must be rebuilt from the + // parsed demand instead, the same way $paginationBaseUrl is. + 'returnUrl' => $this->buildRequestLogUrl($demand), 'paginationBaseUrl' => $paginationBaseUrl, 'paginator' => $paginator, 'pagination' => $pagination, @@ -125,13 +122,22 @@ public function pollAction(ServerRequestInterface $request): ResponseInterface $totalCount = $this->logRepository->countByDemand($demand); $statistics = $this->logRepository->getStatistics(); + $requestLogReturnUrl = $this->buildRequestLogUrl($demand); + $rows = []; foreach ($logEntries as $entry) { + $configurationUid = (int)($entry['configuration_uid'] ?? 0); $rows[] = [ 'crdate' => date('Y-m-d H:i:s', (int)$entry['crdate']), 'extension_key' => $entry['extension_key'] ?? '', 'request_type' => $entry['request_type'] ?? '', 'provider_identifier' => $entry['provider_identifier'] ?? '', + 'configuration_edit_url' => $configurationUid > 0 + ? (string)$this->uriBuilder->buildUriFromRoute('record_edit', [ + 'edit' => ['tx_aim_configuration' => [$configurationUid => 'edit']], + 'returnUrl' => $requestLogReturnUrl, + ]) + : '', 'model_used' => $entry['model_used'] ?: ($entry['model_requested'] ?? ''), 'model_requested' => $entry['model_requested'] ?? '', 'total_tokens' => (int)($entry['total_tokens'] ?? 0), @@ -166,6 +172,40 @@ public function pollAction(ServerRequestInterface $request): ResponseInterface ]); } + /** + * Maps a demand's filters/sorting to route parameters, so a rebuilt + * "aim_request_log" URL reflects the same filter/sort state (deliberately + * excludes "page" — callers append that separately where relevant). + * + * @return array + */ + private function demandToRouteParams(RequestLogDemand $demand): array + { + $params = [ + 'orderField' => $demand->getOrderField(), + 'orderDirection' => $demand->getOrderDirection(), + ]; + foreach ($demand->getParameters() as $key => $value) { + $params['demand[' . $key . ']'] = $value; + } + return $params; + } + + /** + * Rebuilds the "aim_request_log" listing URL for the given demand, including + * the current page. Used as a returnUrl wherever the actual current request + * doesn't reflect the demand (filters are submitted via POST, so they never + * appear in a request's own URI/query string — see Filters.html) or is a + * different request entirely (e.g. pollAction's AJAX endpoint). + */ + private function buildRequestLogUrl(RequestLogDemand $demand): string + { + return (string)$this->uriBuilder->buildUriFromRoute( + 'aim_request_log', + array_merge($this->demandToRouteParams($demand), ['page' => $demand->getPage()]), + ); + } + /** * Resolve extension icon paths for all active packages. * diff --git a/Classes/DependencyInjection/SymfonyAiCompilerPass.php b/Classes/DependencyInjection/SymfonyAiCompilerPass.php index bde0e82..b1689fb 100644 --- a/Classes/DependencyInjection/SymfonyAiCompilerPass.php +++ b/Classes/DependencyInjection/SymfonyAiCompilerPass.php @@ -14,6 +14,7 @@ use B13\Aim\Capability\ConversationCapableInterface; use B13\Aim\Capability\EmbeddingCapableInterface; +use B13\Aim\Capability\ImageGenerationCapableInterface; use B13\Aim\Capability\TextGenerationCapableInterface; use B13\Aim\Capability\ToolCallingCapableInterface; use B13\Aim\Capability\TranslationCapableInterface; @@ -50,8 +51,8 @@ final class SymfonyAiCompilerPass implements CompilerPassInterface * Symfony AI Capability enum → AiM capability interface mapping. * * Only capabilities relevant to AiM's interfaces are mapped. - * Unmapped Symfony AI capabilities (audio, video, image generation, etc.) - * are silently ignored — AiM doesn't support them yet. + * Unmapped Symfony AI capabilities (audio, video, etc.) are silently + * ignored — AiM doesn't support them yet. */ private const CAPABILITY_MAP = [ 'input-image' => VisionCapableInterface::class, @@ -59,11 +60,16 @@ final class SymfonyAiCompilerPass implements CompilerPassInterface 'output-text' => TextGenerationCapableInterface::class, 'tool-calling' => ToolCallingCapableInterface::class, 'embeddings' => EmbeddingCapableInterface::class, + 'output-image' => ImageGenerationCapableInterface::class, ]; /** * All AiM capability interfaces — used as the provider-level default * when a bridge has no ModelCatalog to read from. + * + * Note: ImageGenerationCapableInterface is included here (provider-level "this + * bridge family can do it") but AiProviderManifest::hasModelCapability() special-cases + * it so unlisted/dynamic-catalog models don't silently inherit it, see there for why. */ private const ALL_CAPABILITIES = [ VisionCapableInterface::class, @@ -72,6 +78,7 @@ final class SymfonyAiCompilerPass implements CompilerPassInterface TranslationCapableInterface::class, ToolCallingCapableInterface::class, EmbeddingCapableInterface::class, + ImageGenerationCapableInterface::class, ]; public function process(ContainerBuilder $container): void diff --git a/Classes/Domain/Model/AiProviderManifest.php b/Classes/Domain/Model/AiProviderManifest.php index 529ae1a..4e3e669 100644 --- a/Classes/Domain/Model/AiProviderManifest.php +++ b/Classes/Domain/Model/AiProviderManifest.php @@ -13,6 +13,7 @@ namespace B13\Aim\Domain\Model; use B13\Aim\Capability\AiCapabilityInterface; +use B13\Aim\Capability\ImageGenerationCapableInterface; use B13\Aim\Provider\AiProviderInterface; use B13\Aim\Provider\ProviderFeatures; use Psr\Container\ContainerInterface; @@ -64,9 +65,15 @@ public function hasCapability(string $capabilityFqcn): bool */ public function hasModelCapability(string $model, string $capabilityFqcn): bool { - // No model-level overrides — all models inherit provider capabilities + // No model-level overrides. All models inherit provider capabilities. + // Exception: image generation needs a genuinely different request contract + // (a dedicated image endpoint, not a chat/messages payload) than every other + // capability here, so it's never safe to assume an unlisted model supports it + // (e.g. a dynamic-catalog bridge like Ollama, which has no image endpoint at all). + // It's only granted when a static ModelCatalog explicitly lists it per model. if ($this->modelCapabilities === []) { - return $this->hasCapability($capabilityFqcn); + return $capabilityFqcn !== ImageGenerationCapableInterface::class + && $this->hasCapability($capabilityFqcn); } // Model explicitly listed — use only its declared capabilities if (isset($this->modelCapabilities[$model])) { diff --git a/Classes/Middleware/CapabilityValidationMiddleware.php b/Classes/Middleware/CapabilityValidationMiddleware.php index e8bf173..c968753 100644 --- a/Classes/Middleware/CapabilityValidationMiddleware.php +++ b/Classes/Middleware/CapabilityValidationMiddleware.php @@ -15,6 +15,7 @@ use B13\Aim\Attribute\AsAiMiddleware; use B13\Aim\Capability\ConversationCapableInterface; use B13\Aim\Capability\EmbeddingCapableInterface; +use B13\Aim\Capability\ImageGenerationCapableInterface; use B13\Aim\Capability\TextGenerationCapableInterface; use B13\Aim\Capability\ToolCallingCapableInterface; use B13\Aim\Capability\TranslationCapableInterface; @@ -27,6 +28,7 @@ use B13\Aim\Request\AiRequestInterface; use B13\Aim\Request\ConversationRequest; use B13\Aim\Request\EmbeddingRequest; +use B13\Aim\Request\ImageGenerationRequest; use B13\Aim\Request\TextGenerationRequest; use B13\Aim\Request\ToolCallingRequest; use B13\Aim\Request\TranslationRequest; @@ -57,6 +59,7 @@ final class CapabilityValidationMiddleware implements AiMiddlewareInterface TextGenerationRequest::class => TextGenerationCapableInterface::class, TranslationRequest::class => TranslationCapableInterface::class, EmbeddingRequest::class => EmbeddingCapableInterface::class, + ImageGenerationRequest::class => ImageGenerationCapableInterface::class, ]; public function __construct( diff --git a/Classes/Middleware/CoreDispatchMiddleware.php b/Classes/Middleware/CoreDispatchMiddleware.php index 5e932e9..acb5140 100644 --- a/Classes/Middleware/CoreDispatchMiddleware.php +++ b/Classes/Middleware/CoreDispatchMiddleware.php @@ -15,6 +15,7 @@ use B13\Aim\Attribute\AsAiMiddleware; use B13\Aim\Capability\ConversationCapableInterface; use B13\Aim\Capability\EmbeddingCapableInterface; +use B13\Aim\Capability\ImageGenerationCapableInterface; use B13\Aim\Capability\TextGenerationCapableInterface; use B13\Aim\Capability\ToolCallingCapableInterface; use B13\Aim\Capability\TranslationCapableInterface; @@ -24,6 +25,7 @@ use B13\Aim\Request\AiRequestInterface; use B13\Aim\Request\ConversationRequest; use B13\Aim\Request\EmbeddingRequest; +use B13\Aim\Request\ImageGenerationRequest; use B13\Aim\Request\TextGenerationRequest; use B13\Aim\Request\ToolCallingRequest; use B13\Aim\Request\TranslationRequest; @@ -67,6 +69,8 @@ public function process( => $provider->processTranslationRequest($request), $request instanceof EmbeddingRequest && $provider instanceof EmbeddingCapableInterface => $provider->processEmbeddingRequest($request), + $request instanceof ImageGenerationRequest && $provider instanceof ImageGenerationCapableInterface + => $provider->processImageGenerationRequest($request), default => throw new \LogicException(sprintf( 'Cannot dispatch request of type "%s" to provider "%s". No matching capability found.', get_class($request), diff --git a/Classes/Middleware/SmartRoutingMiddleware.php b/Classes/Middleware/SmartRoutingMiddleware.php index 752d465..6cc178e 100644 --- a/Classes/Middleware/SmartRoutingMiddleware.php +++ b/Classes/Middleware/SmartRoutingMiddleware.php @@ -25,6 +25,7 @@ use B13\Aim\Request\AiRequestInterface; use B13\Aim\Request\ConversationRequest; use B13\Aim\Request\EmbeddingRequest; +use B13\Aim\Request\ImageGenerationRequest; use B13\Aim\Request\TextGenerationRequest; use B13\Aim\Request\ToolCallingRequest; use B13\Aim\Request\TranslationRequest; @@ -94,6 +95,12 @@ public function process( return $next->handle($request, $provider, $configuration); } + // Skip routing for image generation — cost is driven by size/quality, not + // prompt complexity, and the complexity classifier below is text-oriented. + if ($request instanceof ImageGenerationRequest) { + return $next->handle($request, $provider, $configuration); + } + $prompt = $this->extractPrompt($request); if ($prompt === '') { return $next->handle($request, $provider, $configuration); diff --git a/Classes/Provider/ProviderResolver.php b/Classes/Provider/ProviderResolver.php index ffb7945..fbc84c9 100644 --- a/Classes/Provider/ProviderResolver.php +++ b/Classes/Provider/ProviderResolver.php @@ -424,10 +424,18 @@ private function tryAutoModelSwitch(string $capabilityFqcn, array $configuration continue; // Model already supports it — should have been found earlier } - $alternativeModel = $this->pickCheapestModel( - $manifest->findModelsForCapability($capabilityFqcn), - $config->providerIdentifier, + $candidates = $manifest->findModelsForCapability($capabilityFqcn); + // Prefer the most specialized model (fewest capabilities) first, e.g. a + // dedicated image model over a general-purpose chat model that merely also + // supports image output. This also matters functionally: some multi-capability + // models (e.g. OpenAI's "chatgpt-image-latest") use a different request contract + // than single-purpose models (e.g. "gpt-image-1") for the same capability. + usort( + $candidates, + static fn(string $a, string $b): int + => count($manifest->modelCapabilities[$a] ?? []) <=> count($manifest->modelCapabilities[$b] ?? []), ); + $alternativeModel = $this->pickCheapestModel($candidates, $config->providerIdentifier); if ($alternativeModel === null) { continue; } diff --git a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php index 97b4f7b..8612c83 100644 --- a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php +++ b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php @@ -14,6 +14,7 @@ use B13\Aim\Capability\ConversationCapableInterface; use B13\Aim\Capability\EmbeddingCapableInterface; +use B13\Aim\Capability\ImageGenerationCapableInterface; use B13\Aim\Capability\TextGenerationCapableInterface; use B13\Aim\Capability\ToolCallingCapableInterface; use B13\Aim\Capability\TranslationCapableInterface; @@ -22,6 +23,7 @@ use B13\Aim\Provider\AiProviderInterface; use B13\Aim\Request\ConversationRequest; use B13\Aim\Request\EmbeddingRequest; +use B13\Aim\Request\ImageGenerationRequest; use B13\Aim\Request\Message\AbstractMessage; use B13\Aim\Request\TextGenerationRequest; use B13\Aim\Request\ToolCallingRequest; @@ -30,6 +32,8 @@ use B13\Aim\Response\AiUsageStatistics; use B13\Aim\Response\ConversationResponse; use B13\Aim\Response\EmbeddingResponse; +use B13\Aim\Response\GeneratedImage; +use B13\Aim\Response\ImageGenerationResponse; use B13\Aim\Response\StreamChunkIterator; use B13\Aim\Response\TextResponse; use B13\Aim\Response\ToolCall; @@ -38,6 +42,8 @@ use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\ProviderInterface; +use Symfony\AI\Platform\Result\BinaryResult; +use Symfony\AI\Platform\Result\MultiPartResult; use Symfony\AI\Platform\TokenUsage\TokenUsageInterface; /** @@ -59,7 +65,8 @@ class SymfonyAiPlatformAdapter implements TextGenerationCapableInterface, TranslationCapableInterface, ToolCallingCapableInterface, - EmbeddingCapableInterface + EmbeddingCapableInterface, + ImageGenerationCapableInterface { /** @var array Providers cached by configuration key */ private array $platforms = []; @@ -236,6 +243,56 @@ public function processEmbeddingRequest(EmbeddingRequest $request): EmbeddingRes } } + /** + * Generate one or more images from a prompt, optionally guided by a + * reference image (image-to-image / style transfer). + */ + public function processImageGenerationRequest(ImageGenerationRequest $request): ImageGenerationResponse + { + $platform = $this->getPlatform($request->configuration); + + $options = $request->options; + if ($request->count > 1) { + $options['n'] = $request->count; + } + if ($request->referenceImageData !== '') { + $options['image'] = Image::fromDataUrl( + 'data:' . $request->referenceMimeType . ';base64,' . $request->referenceImageData, + ); + } + + try { + $result = $platform->invoke($request->configuration->model, $request->prompt, $options); + $images = $this->extractImages($result); + if ($images === []) { + return new ImageGenerationResponse(errors: ['Provider returned no image data.']); + } + + $usage = $this->extractUsage($result, $request->configuration); + $rawResponse = $this->extractRawResponse($result); + return new ImageGenerationResponse($images, $usage, $rawResponse); + } catch (\Throwable $e) { + return new ImageGenerationResponse(errors: ['Symfony AI error: ' . $e->getMessage()]); + } + } + + /** + * @return list + */ + private function extractImages(object $result): array + { + $resolved = method_exists($result, 'getResult') ? $result->getResult() : $result; + $parts = $resolved instanceof MultiPartResult ? $resolved->getContent() : [$resolved]; + + $images = []; + foreach ($parts as $part) { + if ($part instanceof BinaryResult) { + $images[] = GeneratedImage::fromBase64($part->toBase64(), $part->getMimeType() ?? 'image/png'); + } + } + return $images; + } + /** * Lazily create and cache a Provider instance per provider configuration. */ diff --git a/Classes/Request/ImageGenerationRequest.php b/Classes/Request/ImageGenerationRequest.php new file mode 100644 index 0000000..eb89fcd --- /dev/null +++ b/Classes/Request/ImageGenerationRequest.php @@ -0,0 +1,74 @@ + $options Provider-specific options passed through as-is + * (e.g. "size", "quality", "background", "output_format", "negative_prompt"). + * Left generic since valid keys/values differ per provider and model. + * @param int $count Number of images to generate in one request. + */ + public function __construct( + public readonly ProviderConfiguration $configuration, + public readonly string $prompt, + public readonly string $referenceImageData = '', + public readonly string $referenceMimeType = '', + public readonly array $options = [], + public readonly int $count = 1, + public readonly string $user = '', + public readonly array $metadata = [], + public readonly ?PrivacyLevel $privacyLevelOverride = null, + ) {} + + public function getConfiguration(): ProviderConfiguration + { + return $this->configuration; + } + + public function withConfiguration(ProviderConfiguration $configuration): static + { + return new static(...array_merge( + get_object_vars($this), + ['configuration' => $configuration], + )); + } + + public function withMetadata(array $additional): static + { + return new static(...array_merge( + get_object_vars($this), + ['metadata' => [...$this->metadata, ...$additional]], + )); + } + + public function withPrivacyLevel(PrivacyLevel $level): static + { + return new static(...array_merge( + get_object_vars($this), + ['privacyLevelOverride' => $level], + )); + } + + public function getPrivacyLevelOverride(): ?PrivacyLevel + { + return $this->privacyLevelOverride; + } +} diff --git a/Classes/Response/GeneratedImage.php b/Classes/Response/GeneratedImage.php new file mode 100644 index 0000000..f327955 --- /dev/null +++ b/Classes/Response/GeneratedImage.php @@ -0,0 +1,53 @@ +url !== ''; + } + + /** + * Data URI for inline display/storage. Only meaningful when isUrl() is false. + */ + public function toDataUri(): string + { + return 'data:' . $this->mimeType . ';base64,' . $this->data; + } +} diff --git a/Classes/Response/ImageGenerationResponse.php b/Classes/Response/ImageGenerationResponse.php new file mode 100644 index 0000000..85cd8df --- /dev/null +++ b/Classes/Response/ImageGenerationResponse.php @@ -0,0 +1,36 @@ + $images + */ + public function __construct( + public readonly array $images = [], + AiUsageStatistics $usage = new AiUsageStatistics(), + array $rawResponse = [], + array $errors = [], + ) { + parent::__construct('', $usage, $rawResponse, $errors); + } + + public function isSuccessful(): bool + { + return $this->errors === [] && $this->images !== []; + } +} diff --git a/Documentation/Introduction.md b/Documentation/Introduction.md index 14c8ada..0fddd1b 100644 --- a/Documentation/Introduction.md +++ b/Documentation/Introduction.md @@ -64,6 +64,10 @@ Generate vector embeddings for content. Enable semantic search, find related con Let AI interact with your TYPO3 data. AI can call functions you define: query records, trigger actions, process data. +### Image generation + +Every editor prompting an image generator on their own produces a different look, inconsistent styles, colors, and composition scattered across the site. Instead, pass an existing on-brand image as a **style reference** alongside the prompt. AiM asks the provider to generate an image-to-image edit guided by it, so headers, teasers, and illustrations stay visually consistent site-wide instead of looking like they came from ten different tools. Requires a provider/model that supports image output (e.g. OpenAI's `gpt-image-1`). + --- ## How it works for administrators @@ -283,6 +287,32 @@ foreach ($response->streamIterator as $chunk) { // Embeddings $response = $this->ai->embed('TYPO3 is a CMS', dimensions: 256, extensionKey: 'my_ext'); + +// Image generation +$response = $this->ai->generateImage( + prompt: 'A minimalist header illustration of a lighthouse at sunset', + options: ['size' => '1536x1024', 'quality' => 'high'], // provider-specific, passed through as-is + extensionKey: 'my_ext', +); +if ($response instanceof \B13\Aim\Response\ImageGenerationResponse) { + foreach ($response->images as $image) { + if ($image->isUrl()) { + // Some providers return a temporary URL instead of the bytes. + file_put_contents('header.png', file_get_contents($image->url)); + } else { + // $image->data is base64-encoded, $image->mimeType e.g. "image/png" + file_put_contents('header.png', base64_decode($image->data)); + } + } +} + +// Image generation guided by a reference image (style transfer) +$response = $this->ai->generateImage( + prompt: 'The same lighthouse scene, but as a header for the "About us" page', + referenceImageData: base64_encode(file_get_contents('brand-style-reference.png')), + referenceMimeType: 'image/png', + extensionKey: 'my_ext', +); ``` ### Request a specific provider @@ -315,6 +345,18 @@ $response = $this->ai->request() ->send(); ``` +The same builder covers image generation: + +```php +$response = $this->ai->request() + ->image() + ->prompt('A minimalist header illustration of a lighthouse at sunset') + ->referenceImage($imageData, 'image/png') + ->options(['size' => '1536x1024']) + ->from('my_extension') + ->send(); +``` + ### Register your own AI provider Any extension can add AI providers: diff --git a/README.md b/README.md index bfd1875..96e2d4e 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ A few lines to add AI to any TYPO3 extension. No API keys in your code, no provi ## Key features **For extension developers:** -- Simple proxy API (`$ai->vision()`, `$ai->text()`, `$ai->translate()`, `$ai->embed()`) +- Simple proxy API (`$ai->vision()`, `$ai->text()`, `$ai->translate()`, `$ai->embed()`, `$ai->generateImage()`) - Fluent builder for advanced parameters +- Image generation with reference-image style transfer - Direct pipeline access for full control - Structured output (JSON Schema), tool calling, streaming @@ -160,8 +161,41 @@ $response = $this->ai->embed( dimensions: 256, extensionKey: 'my_extension', ); + +// Image generation +$response = $this->ai->generateImage( + prompt: 'A minimalist header illustration of a lighthouse at sunset', + options: ['size' => '1536x1024', 'quality' => 'high'], // provider-specific, passed through as-is + extensionKey: 'my_extension', +); +if ($response instanceof \B13\Aim\Response\ImageGenerationResponse) { + foreach ($response->images as $image) { + if ($image->isUrl()) { + // Some providers return a temporary URL instead of the bytes. + file_put_contents('header.png', file_get_contents($image->url)); + } else { + file_put_contents('header.png', base64_decode($image->data)); + } + } +} +``` + +#### Image generation with a reference image (style transfer) + +Every editor prompting an image generator on their own produces a different look, inconsistent styles, colors, and composition scattered across the site. Instead, pass an existing on-brand image as a **style reference** alongside the prompt. AiM asks the provider to generate an image-to-image edit guided by it, so headers, teasers, and illustrations stay visually consistent site-wide instead of looking like they came from ten different tools: + +```php +$response = $this->ai->generateImage( + prompt: 'A lighthouse at sunset, for the "About us" page header', + referenceImageData: base64_encode(file_get_contents('brand-style-reference.png')), + referenceMimeType: 'image/png', + options: ['size' => '1536x1024'], + extensionKey: 'my_extension', +); ``` +`options` is a generic pass-through bag since valid keys/values differ per provider (e.g. OpenAI also supports `background` for transparent images and `output_format` for png/jpeg/webp). The same option is available on the fluent builder via `->referenceImage($imageData, $mimeType)` (see [Tier 2](#tier-2-fluent-builder) below). + #### Provider preference Extensions can request a specific provider without hardcoding configuration UIDs: @@ -202,6 +236,18 @@ $response = $this->ai->request() ->send(); ``` +The same builder covers image generation, including the reference-image style transfer shown above: + +```php +$response = $this->ai->request() + ->image() + ->prompt('A lighthouse at sunset, for the "About us" page header') + ->referenceImage($imageData, 'image/png') + ->options(['size' => '1536x1024']) + ->from('my_extension') + ->send(); +``` + ### Tier 3: Direct pipeline access Full control. You choose the provider, build the request, and dispatch through the pipeline: diff --git a/Resources/Private/Partials/RequestLog/Row.html b/Resources/Private/Partials/RequestLog/Row.html index 72c8eec..eac3a21 100644 --- a/Resources/Private/Partials/RequestLog/Row.html +++ b/Resources/Private/Partials/RequestLog/Row.html @@ -1,4 +1,6 @@ - + {entry.crdate -> f:format.date(format: 'Y-m-d H:i:s')} @@ -20,7 +22,21 @@ {entry.request_type} - {entry.provider_identifier} + + + + + {entry.provider_identifier} + + + {entry.provider_identifier} + + {entry.model_used -> f:or(alternative: entry.model_requested)} diff --git a/Resources/Private/Partials/RequestLog/Table.html b/Resources/Private/Partials/RequestLog/Table.html index 3e9e532..d6224eb 100644 --- a/Resources/Private/Partials/RequestLog/Table.html +++ b/Resources/Private/Partials/RequestLog/Table.html @@ -19,7 +19,7 @@ - + diff --git a/Resources/Public/JavaScript/request-log-poll.js b/Resources/Public/JavaScript/request-log-poll.js index 0190acc..8fe0ae2 100644 --- a/Resources/Public/JavaScript/request-log-poll.js +++ b/Resources/Public/JavaScript/request-log-poll.js @@ -107,7 +107,7 @@ class RequestLogPoll { this.#td(e.crdate), this.#tdHtml(this.#renderExtBadge(e.extension_key)), this.#td(e.request_type), - this.#td(e.provider_identifier), + this.#tdHtml(this.#renderProvider(e)), this.#tdHtml(this.#renderModel(e)), this.#tdHtml(this.#renderTokens(e)), this.#td(e.cost), @@ -135,6 +135,12 @@ class RequestLogPoll { : '-'; } + #renderProvider(e) { + return e.configuration_edit_url + ? `${this.#esc(e.provider_identifier)}` + : this.#esc(e.provider_identifier); + } + #renderModel(e) { let html = `${this.#esc(e.model_used)}`; if (e.model_used && e.model_requested && e.model_used !== e.model_requested) { diff --git a/Tests/Unit/Controller/ProviderControllerTest.php b/Tests/Unit/Controller/ProviderControllerTest.php new file mode 100644 index 0000000..85cadaa --- /dev/null +++ b/Tests/Unit/Controller/ProviderControllerTest.php @@ -0,0 +1,195 @@ + 3, + 'ai_provider' => 'openai', + 'model' => 'text-embedding-3-large', + ]); + + $fakeProvider = new class implements AiProviderInterface, ConversationCapableInterface, TextGenerationCapableInterface, EmbeddingCapableInterface { + public function processConversationRequest(ConversationRequest $request): ConversationResponse + { + throw new \RuntimeException('conversation probe must not be sent to an embeddings-only model'); + } + + public function processTextGenerationRequest(TextGenerationRequest $request): TextResponse + { + throw new \RuntimeException('text-generation probe must not be sent to an embeddings-only model'); + } + + public function processEmbeddingRequest(EmbeddingRequest $request): EmbeddingResponse + { + return new EmbeddingResponse([[0.1, 0.2, 0.3]]); + } + }; + + $manifest = $this->manifest([ + 'text-embedding-3-large' => [EmbeddingCapableInterface::class], + 'o3-mini' => [ConversationCapableInterface::class, TextGenerationCapableInterface::class], + ], $fakeProvider); + + $response = $this->verify($manifest, $configuration); + + self::assertTrue($response['ok'], 'Expected the embeddings probe to succeed, got: ' . ($response['message'] ?? '')); + } + + #[Test] + public function imageGenerationOnlyModelIsProbedWithAnImageGenerationRequest(): void + { + $configuration = new ProviderConfiguration([ + 'uid' => 5, + 'ai_provider' => 'openai', + 'model' => 'gpt-image-1', + ]); + + $fakeProvider = new class implements AiProviderInterface, ConversationCapableInterface, TextGenerationCapableInterface, ImageGenerationCapableInterface { + public function processConversationRequest(ConversationRequest $request): ConversationResponse + { + throw new \RuntimeException('conversation probe must not be sent to an image-generation-only model'); + } + + public function processTextGenerationRequest(TextGenerationRequest $request): TextResponse + { + throw new \RuntimeException('text-generation probe must not be sent to an image-generation-only model'); + } + + public function processImageGenerationRequest(ImageGenerationRequest $request): ImageGenerationResponse + { + return new ImageGenerationResponse([GeneratedImage::fromBase64('base64data', 'image/png')]); + } + }; + + $manifest = $this->manifest([ + 'gpt-image-1' => [ImageGenerationCapableInterface::class], + 'o3-mini' => [ConversationCapableInterface::class, TextGenerationCapableInterface::class], + ], $fakeProvider); + + $response = $this->verify($manifest, $configuration); + + self::assertTrue($response['ok'], 'Expected the image-generation probe to succeed, got: ' . ($response['message'] ?? '')); + } + + /** + * @param array> $modelCapabilities + */ + private function manifest(array $modelCapabilities, AiProviderInterface $fakeProvider): AiProviderManifest + { + $container = $this->createStub(ContainerInterface::class); + $container->method('get')->willReturn($fakeProvider); + + return new AiProviderManifest( + identifier: 'openai', + name: 'OpenAI', + description: '', + iconIdentifier: '', + supportedModels: [], + capabilities: [ + ConversationCapableInterface::class, + TextGenerationCapableInterface::class, + EmbeddingCapableInterface::class, + ImageGenerationCapableInterface::class, + ], + serviceName: 'aim.symfony_ai.openai', + container: $container, + modelCapabilities: $modelCapabilities, + ); + } + + /** + * @return array{ok: bool, message?: string} + */ + private function verify(AiProviderManifest $manifest, ProviderConfiguration $configuration): array + { + $configurationRepository = $this->createStub(ProviderConfigurationRepository::class); + $configurationRepository->method('findByUid')->with($configuration->uid)->willReturn($configuration); + + $providerRegistry = $this->createStub(AiProviderRegistry::class); + $providerRegistry->method('hasProvider')->willReturn(true); + $providerRegistry->method('getProvider')->willReturn($manifest); + + $registry = $this->createStub(Registry::class); + $registry->method('get')->willReturn([]); + + // ModuleTemplateFactory and LiveModelDiscovery are `final` classes PHPUnit + // cannot generate test doubles for — and verifyProviderAction() never + // touches either, so an uninitialized instance is a sufficient placeholder. + $moduleTemplateFactory = (new \ReflectionClass(ModuleTemplateFactory::class))->newInstanceWithoutConstructor(); + $liveModelDiscovery = (new \ReflectionClass(LiveModelDiscovery::class))->newInstanceWithoutConstructor(); + + $controller = new ProviderController( + $this->createStub(IconFactory::class), + $this->createStub(UriBuilder::class), + $configurationRepository, + $providerRegistry, + $moduleTemplateFactory, + $this->createStub(DisabledModelRegistry::class), + $this->createStub(RequestLogRepository::class), + $registry, + $liveModelDiscovery, + ); + + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn(['uid' => $configuration->uid]); + + $response = $controller->verifyProviderAction($request); + + return json_decode((string)$response->getBody(), true); + } +} diff --git a/Tests/Unit/Domain/Model/AiProviderManifestTest.php b/Tests/Unit/Domain/Model/AiProviderManifestTest.php new file mode 100644 index 0000000..4323509 --- /dev/null +++ b/Tests/Unit/Domain/Model/AiProviderManifestTest.php @@ -0,0 +1,71 @@ +createStub(ContainerInterface::class), + modelCapabilities: $modelCapabilities, + ); + } + + #[Test] + public function dynamicCatalogProviderInheritsOrdinaryCapabilitiesForAnyModel(): void + { + $manifest = $this->manifest([ConversationCapableInterface::class, ImageGenerationCapableInterface::class]); + + self::assertTrue($manifest->hasModelCapability('llama3.2:latest', ConversationCapableInterface::class)); + } + + #[Test] + public function dynamicCatalogProviderNeverInheritsImageGeneration(): void + { + $manifest = $this->manifest([ConversationCapableInterface::class, ImageGenerationCapableInterface::class]); + + self::assertFalse($manifest->hasModelCapability('llama3.2:latest', ImageGenerationCapableInterface::class)); + } + + #[Test] + public function staticCatalogModelExplicitlyGrantedImageGenerationStillHasIt(): void + { + $manifest = $this->manifest( + [ConversationCapableInterface::class, ImageGenerationCapableInterface::class], + ['gpt-image-1' => [ImageGenerationCapableInterface::class]], + ); + + self::assertTrue($manifest->hasModelCapability('gpt-image-1', ImageGenerationCapableInterface::class)); + } +} diff --git a/Tests/Unit/Provider/ProviderResolverTest.php b/Tests/Unit/Provider/ProviderResolverTest.php new file mode 100644 index 0000000..b41a623 --- /dev/null +++ b/Tests/Unit/Provider/ProviderResolverTest.php @@ -0,0 +1,87 @@ + 1, + 'ai_provider' => 'openai', + 'model' => 'o3-mini', + 'disabled' => 0, + 'auto_model_switch' => 1, + ]); + + // Declaration order mirrors the real OpenAI ModelCatalog: the general-purpose + // "chatgpt-image-latest" model is declared long before the dedicated "gpt-image-1". + $manifest = new AiProviderManifest( + identifier: 'openai', + name: 'OpenAI', + description: '', + iconIdentifier: '', + supportedModels: [], + capabilities: [ConversationCapableInterface::class, ImageGenerationCapableInterface::class], + serviceName: 'aim.symfony_ai.openai', + container: $this->createStub(ContainerInterface::class), + modelCapabilities: [ + 'o3-mini' => [ConversationCapableInterface::class], + 'chatgpt-image-latest' => [ConversationCapableInterface::class, ImageGenerationCapableInterface::class], + 'gpt-image-1' => [ImageGenerationCapableInterface::class], + ], + ); + + $registry = $this->createStub(AiProviderRegistry::class); + $registry->method('hasProvider')->willReturn(true); + $registry->method('getProvider')->willReturn($manifest); + + $configurationRepository = $this->createStub(ProviderConfigurationRepository::class); + $configurationRepository->method('findAll')->willReturn([$configuration]); + + $disabledModelRegistry = $this->createStub(DisabledModelRegistry::class); + $disabledModelRegistry->method('isDisabled')->willReturn(false); + + // No request history yet — this is the exact "first attempt, no cost data" case that broke. + $logRepository = $this->createStub(RequestLogRepository::class); + $logRepository->method('getModelPerformanceProfile')->willReturn([]); + + $resolver = new ProviderResolver($registry, $configurationRepository, $disabledModelRegistry, $logRepository); + + $resolved = $resolver->resolveForCapability(ImageGenerationCapableInterface::class); + + self::assertSame('gpt-image-1', $resolved->configuration->model); + } +} diff --git a/Tests/Unit/Request/WithMetadataTest.php b/Tests/Unit/Request/WithMetadataTest.php index f9d35ac..10760b5 100644 --- a/Tests/Unit/Request/WithMetadataTest.php +++ b/Tests/Unit/Request/WithMetadataTest.php @@ -16,6 +16,7 @@ use B13\Aim\Request\AiRequestInterface; use B13\Aim\Request\ConversationRequest; use B13\Aim\Request\EmbeddingRequest; +use B13\Aim\Request\ImageGenerationRequest; use B13\Aim\Request\Message\UserMessage; use B13\Aim\Request\TextGenerationRequest; use B13\Aim\Request\ToolCallingRequest; @@ -83,6 +84,13 @@ public static function requestProvider(): \Generator metadata: ['existing' => 'value', 'shared' => 'old'], ), ]; + yield 'ImageGenerationRequest' => [ + new ImageGenerationRequest( + configuration: $configuration, + prompt: 'A sunset over mountains', + metadata: ['existing' => 'value', 'shared' => 'old'], + ), + ]; } #[Test] diff --git a/Tests/Unit/Response/ImageGenerationResponseTest.php b/Tests/Unit/Response/ImageGenerationResponseTest.php new file mode 100644 index 0000000..b5811a7 --- /dev/null +++ b/Tests/Unit/Response/ImageGenerationResponseTest.php @@ -0,0 +1,55 @@ +isSuccessful()); + + $withoutImages = new ImageGenerationResponse(); + self::assertFalse($withoutImages->isSuccessful()); + + $withError = new ImageGenerationResponse( + [GeneratedImage::fromBase64('base64data', 'image/png')], + errors: ['provider failed'], + ); + self::assertFalse($withError->isSuccessful()); + } + + #[Test] + public function generatedImageBuildsDataUri(): void + { + $image = GeneratedImage::fromBase64('aGVsbG8=', 'image/png'); + + self::assertFalse($image->isUrl()); + self::assertSame('data:image/png;base64,aGVsbG8=', $image->toDataUri()); + } + + #[Test] + public function generatedImageFromUrlIsFlaggedAsUrl(): void + { + $image = GeneratedImage::fromUrl('https://provider.example/tmp/abc123.png'); + + self::assertTrue($image->isUrl()); + self::assertSame('https://provider.example/tmp/abc123.png', $image->url); + } +} diff --git a/composer.json b/composer.json index 04e60f1..d207bd5 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,11 @@ }, "extra": { "typo3/cms": { - "extension-key": "aim" + "extension-key": "aim", + "Package": { + "providesPackages": {} + }, + "version": "0.2.0" } }, "authors": [