trypost/app/Services/Ai/RecordAiUsage.php
Paulo Castellano 148a2f432f feat: AI image generation pipeline and post creation overhaul
Core changes:
- Replace Unsplash slide pipeline with gpt-image-2 via Laravel AI SDK.
  New AiImageClient builds prompts from a Blade template seeded by the
  workspace's ImageStyle enum, content language, brand color (mapped to a
  human-readable name via HexColorName helper) and brand description.
- Drop Template B from TemplateImageGenerator: every slide now renders as
  Template A (full-bleed photo + bottom gradient + white/grey overlay).
  Removes renderTemplateB, roundCorners, blendHex, ensureContrast and the
  closing-slide pipeline.
- StreamPostCreation creates the Post directly and dispatches
  PostCreationReady with post_id; the wizard kills its preview step and
  redirects straight to the post editor on completion. Finalize endpoint
  removed.
- New Workspace.image_style enum field with an 8-option visual picker
  shared by /workspaces/create and /settings/workspace/brand via a single
  BrandForm component (autofill is a prop). 8 sample webp thumbs ship
  under public/images/branding/image-styles/.
- Media items gain optional source ('ai'|'unsplash'|'giphy') and
  source_meta (recipe needed to regenerate AI images later); the gallery
  picker tags Unsplash/Giphy attachments.
- Brand-color autofill: new CssColorFrequencyExtractor parses every
  hex/rgb/hsl value in the homepage CSS, clusters perceptually similar
  shades in CIE LAB (Delta E 76 < 12), filters neutrals and returns the
  most frequent cluster. Solves Tailwind/utility-CSS sites where no
  semantic --primary variable is exposed.
- Credits: gpt-image-2 metered at 15 credits/image (low quality default).
- Layout: AuthSplitLayout right column is sticky/h-svh so the form
  textarea growth no longer stretches the marketing slider.
- i18n cleanup: localized labels follow the no-em-dash convention.
2026-05-08 13:38:30 -03:00

156 lines
4.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Ai;
use App\Enums\Ai\UsageType;
use App\Models\AiUsageLog;
use App\Models\Workspace;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Persists an AI usage row and debits credits from the account's monthly
* quota. Credits are billed at the account level (Workspace::account_id);
* workspace_id is recorded for analytics.
*
* Wraps the create in a try/catch so a tracking failure NEVER bubbles up
* and breaks the actual AI flow — at worst we miss a usage row and the
* user gets unblocked.
*/
final class RecordAiUsage
{
/**
* Record a usage entry for a text generation. Credits are computed from
* total_tokens via CreditCost::forText().
*
* @param array<string, mixed> $metadata
*/
public static function recordText(
Workspace $workspace,
int $promptTokens,
int $completionTokens,
?string $provider = null,
?string $model = null,
?string $userId = null,
?string $postId = null,
array $metadata = [],
): void {
$totalTokens = $promptTokens + $completionTokens;
$credits = CreditCost::forText($totalTokens);
self::persist(
workspace: $workspace,
type: UsageType::Text,
credits: $credits,
provider: $provider,
model: $model,
promptTokens: $promptTokens,
completionTokens: $completionTokens,
totalTokens: $totalTokens,
userId: $userId,
postId: $postId,
metadata: $metadata,
);
}
/**
* Record a usage entry for an AI image generation (gpt-image-* etc.).
* Credits are flat per call via CreditCost::forImage($model).
*
* @param array<string, mixed> $metadata
*/
public static function recordImage(
Workspace $workspace,
string $provider,
string $model,
?string $userId = null,
?string $postId = null,
array $metadata = [],
): void {
$credits = CreditCost::forImage($model);
self::persist(
workspace: $workspace,
type: UsageType::Image,
credits: $credits,
provider: $provider,
model: $model,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
userId: $userId,
postId: $postId,
metadata: $metadata,
);
}
/**
* Record a usage entry for an image-template generation. Templates do not
* call an LLM (composed via Unsplash + branding) so we charge zero credits.
*
* @param array<string, mixed> $metadata
*/
public static function recordTemplate(
Workspace $workspace,
?string $provider = null,
?string $userId = null,
?string $postId = null,
array $metadata = [],
): void {
self::persist(
workspace: $workspace,
type: UsageType::Template,
credits: 0,
provider: $provider,
model: null,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
userId: $userId,
postId: $postId,
metadata: $metadata,
);
}
/**
* @param array<string, mixed> $metadata
*/
private static function persist(
Workspace $workspace,
UsageType $type,
int $credits,
?string $provider,
?string $model,
int $promptTokens,
int $completionTokens,
int $totalTokens,
?string $userId,
?string $postId,
array $metadata,
): void {
try {
AiUsageLog::create([
'account_id' => $workspace->account_id,
'workspace_id' => $workspace->id,
'user_id' => $userId,
'post_id' => $postId,
'type' => $type,
'provider' => $provider,
'model' => $model,
'prompt_tokens' => $promptTokens,
'completion_tokens' => $completionTokens,
'total_tokens' => $totalTokens,
'credits' => $credits,
'metadata' => $metadata !== [] ? $metadata : null,
]);
} catch (Throwable $e) {
Log::warning('Failed to record AI usage', [
'workspace_id' => $workspace->id,
'type' => $type->value,
'error' => $e->getMessage(),
]);
}
}
}