Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 36 additions & 0 deletions Classes/Ai.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, mixed> $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.
*/
Expand Down
69 changes: 68 additions & 1 deletion Classes/AiRequestBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions Classes/Capability/ImageGenerationCapableInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

/*
* This file is part of TYPO3 CMS-based extension "aim" by b13.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*/

namespace B13\Aim\Capability;

use B13\Aim\Request\ImageGenerationRequest;
use B13\Aim\Response\ImageGenerationResponse;

interface ImageGenerationCapableInterface extends AiCapabilityInterface
{
public function processImageGenerationRequest(ImageGenerationRequest $request): ImageGenerationResponse;
}
38 changes: 34 additions & 4 deletions Classes/Controller/ProviderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
namespace B13\Aim\Controller;

use B13\Aim\Capability\ConversationCapableInterface;
use B13\Aim\Capability\EmbeddingCapableInterface;
use B13\Aim\Capability\ImageGenerationCapableInterface;
use B13\Aim\Capability\TextGenerationCapableInterface;
use B13\Aim\Domain\Model\AiProviderManifest;
use B13\Aim\Domain\Model\ProviderConfiguration;
Expand All @@ -24,6 +26,8 @@
use B13\Aim\Registry\AiProviderRegistry;
use B13\Aim\Registry\DisabledModelRegistry;
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 Psr\Http\Message\ResponseInterface;
Expand Down Expand Up @@ -301,28 +305,54 @@ public function verifyProviderAction(ServerRequestInterface $request): ResponseI
$manifest = $this->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 = [
Expand Down
56 changes: 48 additions & 8 deletions Classes/Controller/RequestLogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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<string, string>
*/
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.
*
Expand Down
11 changes: 9 additions & 2 deletions Classes/DependencyInjection/SymfonyAiCompilerPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,20 +51,25 @@ 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,
'input-messages' => ConversationCapableInterface::class,
'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,
Expand All @@ -72,6 +78,7 @@ final class SymfonyAiCompilerPass implements CompilerPassInterface
TranslationCapableInterface::class,
ToolCallingCapableInterface::class,
EmbeddingCapableInterface::class,
ImageGenerationCapableInterface::class,
];

public function process(ContainerBuilder $container): void
Expand Down
Loading