From 4efaa0bf99086fafdff4c17e900f5727a42f6f07 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 10 Jun 2026 20:45:01 -0300 Subject: [PATCH] Harden automations module: full-post generation, reliable runs, editor UX Generation - Generate node now produces the full post (text + AI image + carousel) via a shared PostImagePipeline extracted from StreamPostCreation - Generate config UI mirrors the /posts/create wizard (carousel slide count, include-image toggle); drop the decorative format/unsplash keys Flow correctness - RSS/HTTP nodes expose named has-items (default) and no-items output handles, labeled and colored like the Condition node - AdvanceAutomationRun records a no_matching_edge terminal instead of completing silently; "0 new items" feedback in the test panel - Manual/test runs no longer persist the production dedup watermark Run reliability - Pause truly halts in-flight runs (production only; manual test runs always run regardless of automation status) - ProcessAutomationNode::failed() marks the run failed - automation:recover-stuck-runs and automation:prune-dry-runs commands Webhook / HTTP - Branded User-Agent (config-driven) on outbound webhook + http_request - Webhook fails on invalid JSON instead of silently sending {} - HTTP custom headers editor; CodeMirror-based CodeEditor for JSON Editor UX - Header Test button only opens the panel; the panel has a Run button (saves first) and owns the with-real-data toggle - Clicking a node closes the test panel and opens its config - Node cards: max-width + truncate so long URLs don't grow the node --- .../Automation/Node/RunFetchRssNode.php | 6 +- .../Automation/Node/RunGenerateNode.php | 48 +++- .../Automation/Node/RunHttpRequestNode.php | 8 +- .../Automation/Node/RunWebhookNode.php | 17 +- .../Automation/Run/AdvanceAutomationRun.php | 5 + app/Actions/Automation/Run/TestAutomation.php | 5 +- .../Automation/ProcessAutomationDelays.php | 4 + .../Automation/PruneDryRunAutomationRuns.php | 30 +++ .../Automation/RecoverStuckAutomationRuns.php | 32 +++ app/Jobs/Ai/StreamPostCreation.php | 116 ++------ app/Jobs/Automation/ProcessAutomationNode.php | 20 ++ app/Services/Image/PostImagePipeline.php | 120 +++++++++ config/trypost.php | 13 + lang/en/automations.php | 23 +- lang/es/automations.php | 23 +- lang/pt-BR/automations.php | 23 +- package-lock.json | 170 ++++++++++++ package.json | 5 + resources/css/app.css | 6 + resources/js/components/CodeEditor.vue | 159 +++++++++++ .../components/automations/TestRunPanel.vue | 87 +++--- .../automations/config/GenerateNodeConfig.vue | 58 ++-- .../config/HttpRequestNodeConfig.vue | 93 ++++++- .../automations/config/WebhookNodeConfig.vue | 24 +- .../automations/nodes/FetchRssNode.vue | 17 +- .../automations/nodes/HttpRequestNode.vue | 17 +- resources/js/pages/automations/Form.vue | 66 +++-- routes/console.php | 4 + .../Command/ProcessAutomationDelaysTest.php | 2 +- .../Command/PruneDryRunAutomationRunsTest.php | 36 +++ .../RecoverStuckAutomationRunsTest.php | 44 +++ .../Automation/Node/FetchRssNodeTest.php | 124 +++++++++ .../Automation/Node/GenerateNodeTest.php | 168 ++++++++++++ .../Automation/Node/HttpRequestNodeTest.php | 32 +++ .../Automation/Node/WebhookNodeTest.php | 33 +++ .../Feature/Automation/PauseHaltsRunsTest.php | 75 ++++++ .../Run/AdvanceUnmatchedHandleTest.php | 25 ++ .../Run/JobFailureMarksRunFailedTest.php | 32 +++ .../Tree/ContentPipelineTreesTest.php | 252 ++++++++++++++++++ tests/Feature/Image/PostImagePipelineTest.php | 107 ++++++++ 40 files changed, 1898 insertions(+), 231 deletions(-) create mode 100644 app/Console/Commands/Automation/PruneDryRunAutomationRuns.php create mode 100644 app/Console/Commands/Automation/RecoverStuckAutomationRuns.php create mode 100644 app/Services/Image/PostImagePipeline.php create mode 100644 resources/js/components/CodeEditor.vue create mode 100644 tests/Feature/Automation/Command/PruneDryRunAutomationRunsTest.php create mode 100644 tests/Feature/Automation/Command/RecoverStuckAutomationRunsTest.php create mode 100644 tests/Feature/Automation/PauseHaltsRunsTest.php create mode 100644 tests/Feature/Automation/Run/AdvanceUnmatchedHandleTest.php create mode 100644 tests/Feature/Automation/Run/JobFailureMarksRunFailedTest.php create mode 100644 tests/Feature/Automation/Tree/ContentPipelineTreesTest.php create mode 100644 tests/Feature/Image/PostImagePipelineTest.php diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index 691f43df..e7374d9b 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -30,6 +30,8 @@ */ class RunFetchRssNode { + private const ITEM_HANDLE = 'default'; + public function __invoke(AutomationRun $run, array $config): NodeRunResult { $feedUrl = (string) data_get($config, 'feed_url', ''); @@ -68,7 +70,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult [$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark); - if ($state !== null && $newestSeen !== null) { + if ($state !== null && $newestSeen !== null && ! $run->is_manual) { $state->update(['data' => array_merge($state->data ?? [], [ 'last_item_date' => $newestSeen->toIso8601String(), ])]); @@ -167,7 +169,7 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string { $connection = collect($run->automation->connections ?? []) - ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default'); + ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE); return $connection['target'] ?? null; } diff --git a/app/Actions/Automation/Node/RunGenerateNode.php b/app/Actions/Automation/Node/RunGenerateNode.php index 96c8006f..7e71f1ba 100644 --- a/app/Actions/Automation/Node/RunGenerateNode.php +++ b/app/Actions/Automation/Node/RunGenerateNode.php @@ -15,6 +15,7 @@ use App\Models\Workspace; use App\Services\Ai\RecordAiUsage; use App\Services\Automation\ExpressionResolver; +use App\Services\Image\PostImagePipeline; use Illuminate\Support\Facades\Log; class RunGenerateNode @@ -100,6 +101,18 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ]; } + $includeImage = (bool) data_get($config, 'include_image', true); + + $brandAccount = $platforms !== [] + ? $activeAccounts->get(data_get($platforms[0], 'social_account_id')) + : null; + + $contentType = $platforms !== [] + ? ContentType::tryFrom((string) data_get($platforms[0], 'content_type')) + : null; + + $imageCount = $this->intendedImageCount($format, $slideCount, $includeImage, $structured, $brandAccount); + // Dry runs do the AI work (so the user sees a real generation) but // never persist a Post. Downstream nodes (Publish) read `is_dry_run` // and skip their persistence too. @@ -109,13 +122,24 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult 'post_id' => null, 'content' => $content, 'dry_run' => true, + 'image_count' => $imageCount, ], ]); } + $media = []; + + if ($brandAccount) { + if ($format === 'carousel') { + $media = app(PostImagePipeline::class)->forCarousel($workspace, $brandAccount, $structured, $contentType); + } elseif ($includeImage) { + $media = app(PostImagePipeline::class)->forSingle($workspace, $brandAccount, $structured, $contentType); + } + } + $post = CreatePost::execute($workspace, $user, [ 'content' => $content, - 'media' => [], + 'media' => $media, 'platforms' => $platforms, ]); @@ -239,6 +263,28 @@ public function deriveFormat(array $accountsConfig, array $config): array return ['format' => 'single', 'slide_count' => 1]; } + /** + * Number of images that would be attached for the resolved format. Used as + * the dry-run indicator and mirrors the non-dry image generation branches: + * one per slide for carousels, one for single posts when images are enabled. + * + * @param array $structured + */ + private function intendedImageCount(string $format, int $slideCount, bool $includeImage, array $structured, ?SocialAccount $brandAccount): int + { + if (! $brandAccount) { + return 0; + } + + if ($format === 'carousel') { + $slides = data_get($structured, 'slides', []); + + return is_array($slides) ? count($slides) : $slideCount; + } + + return $includeImage ? 1 : 0; + } + private function resolveUser(AutomationRun $run): User { if ($run->automation->user_id) { diff --git a/app/Actions/Automation/Node/RunHttpRequestNode.php b/app/Actions/Automation/Node/RunHttpRequestNode.php index bf24074c..67115183 100644 --- a/app/Actions/Automation/Node/RunHttpRequestNode.php +++ b/app/Actions/Automation/Node/RunHttpRequestNode.php @@ -33,6 +33,8 @@ */ class RunHttpRequestNode { + private const ITEM_HANDLE = 'default'; + public function __construct(private ExpressionResolver $resolver) {} public function __invoke(AutomationRun $run, array $config): NodeRunResult @@ -130,7 +132,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult $newItems[] = array_merge($item, ['_key' => $key]); } - if ($useWatermark && $newestSeen !== null && $state !== null) { + if ($useWatermark && $newestSeen !== null && $state !== null && ! $run->is_manual) { $state->update(['data' => array_merge($state->data ?? [], [ 'last_item_date' => $newestSeen->toIso8601String(), ])]); @@ -194,7 +196,7 @@ private function buildRequest(array $config, array $context): PendingRequest $request = $request->withHeaders($headers); } - return $request; + return $request->withUserAgent(config('trypost.user_agent')); } /** @@ -252,7 +254,7 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string { $connection = collect($run->automation->connections ?? []) - ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default'); + ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE); return $connection['target'] ?? null; } diff --git a/app/Actions/Automation/Node/RunWebhookNode.php b/app/Actions/Automation/Node/RunWebhookNode.php index 8ff26a81..11387952 100644 --- a/app/Actions/Automation/Node/RunWebhookNode.php +++ b/app/Actions/Automation/Node/RunWebhookNode.php @@ -24,9 +24,24 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult } $payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $run->context ?? []); - $payload = json_decode($payloadJson, true) ?? []; + $trimmedPayload = trim($payloadJson); + + if ($trimmedPayload !== '' && $trimmedPayload !== 'null') { + $decoded = json_decode($payloadJson, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return NodeRunResult::failed(__('automations.errors.webhook_invalid_payload_json'), [ + 'reason' => 'invalid_payload_json', + ]); + } + + $payload = $decoded ?? []; + } else { + $payload = []; + } $response = Http::withHeaders($headers) + ->withUserAgent(config('trypost.user_agent')) ->send($method, $url, ['json' => $payload]); if ($response->serverError()) { diff --git a/app/Actions/Automation/Run/AdvanceAutomationRun.php b/app/Actions/Automation/Run/AdvanceAutomationRun.php index 41b78844..c6d2802c 100644 --- a/app/Actions/Automation/Run/AdvanceAutomationRun.php +++ b/app/Actions/Automation/Run/AdvanceAutomationRun.php @@ -22,6 +22,11 @@ public function __invoke(AutomationRun $run, string $fromNodeId, string $handle 'status' => Status::Completed, 'finished_at' => now(), 'current_node_id' => null, + 'error' => [ + 'reason' => 'no_matching_edge', + 'handle' => $handle, + 'node_id' => $fromNodeId, + ], ]); return; diff --git a/app/Actions/Automation/Run/TestAutomation.php b/app/Actions/Automation/Run/TestAutomation.php index 85420d39..b6a740c1 100644 --- a/app/Actions/Automation/Run/TestAutomation.php +++ b/app/Actions/Automation/Run/TestAutomation.php @@ -20,8 +20,9 @@ * * When `$withRealData` is false (the default), the run is marked `is_dry_run` * so side-effectful nodes (publish, generate, watermark advancement, sibling - * spawning) short-circuit. The run row is auto-deleted after reaching a - * terminal state — dry tests intentionally leave no trace. + * spawning) short-circuit. Dry-run rows are kept briefly so the editor test + * panel can show the completed result, then reaped by the scheduled + * `automation:prune-dry-runs` command once past its grace window. */ class TestAutomation { diff --git a/app/Console/Commands/Automation/ProcessAutomationDelays.php b/app/Console/Commands/Automation/ProcessAutomationDelays.php index 346764c7..211f0bbb 100644 --- a/app/Console/Commands/Automation/ProcessAutomationDelays.php +++ b/app/Console/Commands/Automation/ProcessAutomationDelays.php @@ -6,6 +6,7 @@ use App\Actions\Automation\Run\AdvanceAutomationRun; use App\Enums\Automation\Run\Status; +use App\Enums\Automation\Status as AutomationStatus; use App\Models\AutomationRun; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; @@ -21,6 +22,9 @@ public function handle(AdvanceAutomationRun $advance): int AutomationRun::query() ->where('status', Status::Waiting) ->where('next_action_at', '<=', now()) + ->where(fn ($query) => $query + ->where('is_manual', true) + ->orWhereHas('automation', fn ($inner) => $inner->where('status', AutomationStatus::Active))) ->lockForUpdate() ->chunkById(50, function ($runs) use ($advance) { DB::transaction(function () use ($runs, $advance) { diff --git a/app/Console/Commands/Automation/PruneDryRunAutomationRuns.php b/app/Console/Commands/Automation/PruneDryRunAutomationRuns.php new file mode 100644 index 00000000..a8ee71af --- /dev/null +++ b/app/Console/Commands/Automation/PruneDryRunAutomationRuns.php @@ -0,0 +1,30 @@ +where('is_dry_run', true) + ->whereNotNull('finished_at') + ->where('finished_at', '<=', now()->subMinutes(self::GRACE_MINUTES)) + ->delete(); + + $this->info("Pruned {$count} dry-run automation runs."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Automation/RecoverStuckAutomationRuns.php b/app/Console/Commands/Automation/RecoverStuckAutomationRuns.php new file mode 100644 index 00000000..53951c42 --- /dev/null +++ b/app/Console/Commands/Automation/RecoverStuckAutomationRuns.php @@ -0,0 +1,32 @@ +whereIn('status', [Status::Running, Status::Pending]) + ->where('updated_at', '<=', now()->subHour()) + ->update([ + 'status' => Status::Failed, + 'error' => ['reason' => 'stuck'], + 'finished_at' => now(), + ]); + + $this->info("Recovered {$count} stuck automation runs."); + + return self::SUCCESS; + } +} diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 1406c11c..2ea08d98 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -7,8 +7,6 @@ use App\Actions\Post\CreatePost; use App\Ai\Agents\PostContentGenerator; use App\Ai\Agents\PostContentHumanizer; -use App\Enums\Media\Source; -use App\Enums\Media\Type as MediaType; use App\Enums\Notification\Channel as NotificationChannel; use App\Enums\Notification\Type as NotificationType; use App\Enums\PostPlatform\ContentType; @@ -18,17 +16,14 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; -use App\Services\Ai\AiImageClient; use App\Services\Ai\RecordAiUsage; -use App\Services\Image\BrandColorMapper; -use App\Services\Image\TemplateImageGenerator; +use App\Services\Image\PostImagePipeline; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; class StreamPostCreation implements ShouldQueue { @@ -101,32 +96,6 @@ public function handle(): void } } - /** - * Run the structured generator output through the humanizer pass and merge - * the humanized text fields back over the original structure (preserving - * image_keywords and slide order/count). Failures are logged and the - * original structure is returned so generation never breaks because of the - * polish step. - * - * @param array $structured - * @return array - */ - /** - * Look up the AI image dimensions for the current format. Falls back to - * the generator's defaults (4:5 portrait) if the format string isn't a - * known ContentType case. - * - * @return array{width: int, height: int} - */ - private function dimensionsForFormat(): array - { - $type = $this->resolvedContentType(); - - return $type - ? $type->aiImageDimensions() - : ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT]; - } - /** * The stored content type for the requested generation format. The carousel * generation format is persisted as an Instagram feed post. @@ -140,6 +109,16 @@ private function resolvedContentType(): ?ContentType return ContentType::tryFrom($this->format); } + /** + * Run the structured generator output through the humanizer pass and merge + * the humanized text fields back over the original structure (preserving + * image_keywords and slide order/count). Failures are logged and the + * original structure is returned so generation never breaks because of the + * polish step. + * + * @param array $structured + * @return array + */ private function humanize(Workspace $workspace, array $structured, string $format): array { try { @@ -207,29 +186,16 @@ private function humanize(Workspace $workspace, array $structured, string $forma private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void { $caption = (string) data_get($structured, 'caption', ''); - $slides = data_get($structured, 'slides', []); $media = []; if ($socialAccount) { - $generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient); - ['width' => $width, 'height' => $height] = $this->dimensionsForFormat(); - - foreach ($slides as $slide) { - $rendered = $generator->render( - workspace: $workspace, - socialAccount: $socialAccount, - title: data_get($slide, 'title', ''), - body: data_get($slide, 'body', ''), - imageKeywords: data_get($slide, 'image_keywords', []), - width: $width, - height: $height, - ); - - if ($rendered) { - $media[] = $this->buildAiMediaItem($workspace, $rendered); - } - } + $media = app(PostImagePipeline::class)->forCarousel( + workspace: $workspace, + account: $socialAccount, + structured: $structured, + contentType: $this->resolvedContentType(), + ); } $post = $this->createPost($workspace, $caption, $media, $socialAccount); @@ -246,29 +212,16 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun $supportsCaption = $contentType?->supportsCaption() ?? true; $rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', '')); - $imageTitle = (string) data_get($structured, 'image_title', ''); - $imageBody = (string) data_get($structured, 'image_body', ''); - $keywords = data_get($structured, 'image_keywords', []); $media = []; if ($this->imageCount > 0 && $socialAccount) { - $generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient); - ['width' => $width, 'height' => $height] = $this->dimensionsForFormat(); - - $rendered = $generator->render( + $media = app(PostImagePipeline::class)->forSingle( workspace: $workspace, - socialAccount: $socialAccount, - title: $imageTitle, - body: $imageBody, - imageKeywords: $keywords, - width: $width, - height: $height, + account: $socialAccount, + structured: $structured, + contentType: $this->resolvedContentType(), ); - - if ($rendered) { - $media[] = $this->buildAiMediaItem($workspace, $rendered); - } } $caption = $supportsCaption ? $rawContent : ''; @@ -345,31 +298,4 @@ private function aspectRatioFor(ContentType $type): ?string default => null, }; } - - /** - * @param array{path: string, source_meta: array} $rendered - * @return array - */ - private function buildAiMediaItem(Workspace $workspace, array $rendered): array - { - $media = $workspace->media()->create([ - 'collection' => 'ai-generated', - 'type' => MediaType::Image, - 'path' => $rendered['path'], - 'original_filename' => basename($rendered['path']), - 'mime_type' => 'image/webp', - 'size' => Storage::size($rendered['path']), - 'order' => 0, - ]); - - return [ - 'id' => $media->id, - 'path' => $media->path, - 'url' => $media->url, - 'type' => 'image', - 'mime_type' => 'image/webp', - 'source' => Source::Ai->value, - 'source_meta' => $rendered['source_meta'], - ]; - } } diff --git a/app/Jobs/Automation/ProcessAutomationNode.php b/app/Jobs/Automation/ProcessAutomationNode.php index 41c9b57f..d59d2844 100644 --- a/app/Jobs/Automation/ProcessAutomationNode.php +++ b/app/Jobs/Automation/ProcessAutomationNode.php @@ -17,6 +17,7 @@ use App\Enums\Automation\Node\Type as NodeType; use App\Enums\Automation\NodeRun\Status as NodeRunStatus; use App\Enums\Automation\Run\Status as RunStatus; +use App\Enums\Automation\Status as AutomationStatus; use App\Models\AutomationNodeRun; use App\Models\AutomationRun; use Illuminate\Bus\Queueable; @@ -48,6 +49,10 @@ public function handle(AdvanceAutomationRun $advance): void return; } + if (! $this->run->is_manual && $this->run->automation->status !== AutomationStatus::Active) { + return; + } + $node = collect($this->run->automation->nodes ?? [])->firstWhere('id', $this->nodeId); if ($node === null) { @@ -116,6 +121,21 @@ public function handle(AdvanceAutomationRun $advance): void $advance($this->run, $this->nodeId, $result->nextHandle); } + public function failed(?Throwable $e): void + { + $this->run->refresh(); + + if (in_array($this->run->status, [RunStatus::Completed, RunStatus::Failed], true)) { + return; + } + + $this->run->update([ + 'status' => RunStatus::Failed, + 'error' => ['message' => $e?->getMessage() ?? 'job failed', 'node_id' => $this->nodeId], + 'finished_at' => now(), + ]); + } + private function executeNode(NodeType $type, array $config): NodeRunResult { $handler = match ($type) { diff --git a/app/Services/Image/PostImagePipeline.php b/app/Services/Image/PostImagePipeline.php new file mode 100644 index 00000000..d09d1815 --- /dev/null +++ b/app/Services/Image/PostImagePipeline.php @@ -0,0 +1,120 @@ + $structured + * @return array> + */ + public function forSingle(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType): array + { + ['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType); + + $rendered = $this->generator->render( + workspace: $workspace, + socialAccount: $account, + title: (string) data_get($structured, 'image_title', ''), + body: (string) data_get($structured, 'image_body', ''), + imageKeywords: data_get($structured, 'image_keywords', []), + width: $width, + height: $height, + ); + + if (! $rendered) { + return []; + } + + return [$this->buildAiMediaItem($workspace, $rendered)]; + } + + /** + * Render one AI image per slide in the structured carousel output. Slides + * that render nothing are skipped. + * + * @param array $structured + * @return array> + */ + public function forCarousel(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType): array + { + ['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType); + + $media = []; + + foreach (data_get($structured, 'slides', []) as $slide) { + $rendered = $this->generator->render( + workspace: $workspace, + socialAccount: $account, + title: (string) data_get($slide, 'title', ''), + body: (string) data_get($slide, 'body', ''), + imageKeywords: data_get($slide, 'image_keywords', []), + width: $width, + height: $height, + ); + + if ($rendered) { + $media[] = $this->buildAiMediaItem($workspace, $rendered); + } + } + + return $media; + } + + /** + * Resolve the AI image dimensions for the given content type, falling back + * to the generator defaults (4:5 portrait) when no content type is known. + * + * @return array{width: int, height: int} + */ + private function dimensionsForContentType(?ContentType $contentType): array + { + return $contentType + ? $contentType->aiImageDimensions() + : ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT]; + } + + /** + * @param array{path: string, source_meta: array} $rendered + * @return array + */ + private function buildAiMediaItem(Workspace $workspace, array $rendered): array + { + $media = $workspace->media()->create([ + 'collection' => 'ai-generated', + 'type' => MediaType::Image, + 'path' => $rendered['path'], + 'original_filename' => basename($rendered['path']), + 'mime_type' => 'image/webp', + 'size' => Storage::size($rendered['path']), + 'order' => 0, + ]); + + return [ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'type' => 'image', + 'mime_type' => 'image/webp', + 'source' => Source::Ai->value, + 'source_meta' => $rendered['source_meta'], + ]; + } +} diff --git a/config/trypost.php b/config/trypost.php index a541cd47..082e1357 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -59,6 +59,19 @@ | */ + /* + |-------------------------------------------------------------------------- + | Outbound User-Agent + |-------------------------------------------------------------------------- + | + | Branded User-Agent applied to outbound HTTP from automation nodes + | (webhook + http_request) so recipients know the request came from + | TryPost.it. Self-hosters can override it. + | + */ + + 'user_agent' => env('TRYPOST_USER_AGENT', 'TryPost.it/1.0 (+https://trypost.it)'), + 'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false), 'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false), diff --git a/lang/en/automations.php b/lang/en/automations.php index 91a91d9d..19ffe0f6 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -31,8 +31,11 @@ 'node_input' => 'Input', 'node_output' => 'Output', 'node_error' => 'Error', + 'no_new_items' => 'No new items — nothing downstream ran.', 'error_starting' => 'Could not start the test run.', 'with_real_data' => 'With real data', + 'run' => 'Run test', + 'idle_hint' => 'Hit Run test to execute the automation end-to-end.', 'real_data_hint' => 'This test will publish posts, advance polling watermarks, and trigger external side effects.', 'dry_badge' => 'Dry run', ], @@ -90,10 +93,15 @@ 'end_summary' => 'Stops the automation here', 'fetch_rss' => 'Fetch RSS', 'http_request' => 'HTTP Request', + 'handles' => [ + 'items' => 'has items', + 'no_items' => 'no items', + ], ], 'config' => [ 'select_placeholder' => 'Select…', + 'invalid_json' => 'This isn’t valid JSON yet.', 'trigger' => [ 'type' => 'Trigger type', @@ -146,14 +154,10 @@ 'generate' => [ 'social_accounts' => 'Social accounts', 'social_accounts_empty' => 'No connected social accounts. Connect one first.', - 'target_slide_count' => 'Slides to generate (for carousel-capable platforms)', + 'target_slide_count' => 'Slides to generate', 'prompt_template' => 'Prompt template', - 'image_source' => 'Image source', - 'image_sources' => [ - 'ai' => 'AI generated', - 'unsplash' => 'Unsplash', - 'none' => 'No image', - ], + 'include_image' => 'Include image', + 'include_image_hint' => 'Generate an AI image for this post', ], 'delay' => [ 'duration' => 'Duration', @@ -216,6 +220,10 @@ 'api_key_header' => 'Header name', 'api_key_value' => 'API key', 'body_template' => 'Body template (JSON)', + 'headers' => 'Headers', + 'header_name' => 'Header name', + 'header_value' => 'Value', + 'add_header' => 'Add header', 'polling_section' => 'Polling (optional)', 'polling_hint' => 'Leave blank to use the whole response as a single payload. Fill in to extract an array of items and spawn one run per item.', 'items_path' => 'Items path', @@ -244,6 +252,7 @@ 'only_failed_can_retry' => 'Only failed runs can be retried.', 'no_generated_post' => 'No generated post found on run.', 'webhook_server_error' => 'Webhook server error.', + 'webhook_invalid_payload_json' => 'The payload template is not valid JSON.', 'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.', 'no_trigger_connection' => 'No node connected to the Trigger node.', ], diff --git a/lang/es/automations.php b/lang/es/automations.php index 754b0f97..62a4d1e3 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -31,8 +31,11 @@ 'node_input' => 'Entrada', 'node_output' => 'Salida', 'node_error' => 'Error', + 'no_new_items' => 'Sin elementos nuevos — no se ejecutó nada después.', 'error_starting' => 'No se pudo iniciar la ejecución de prueba.', 'with_real_data' => 'Con datos reales', + 'run' => 'Ejecutar prueba', + 'idle_hint' => 'Pulsa Ejecutar prueba para correr la automatización de principio a fin.', 'real_data_hint' => 'Esta prueba publicará posts, avanzará marcadores de polling y disparará efectos secundarios externos.', 'dry_badge' => 'Prueba seca', ], @@ -90,10 +93,15 @@ 'end_summary' => 'Termina la automatización aquí', 'fetch_rss' => 'Obtener RSS', 'http_request' => 'Petición HTTP', + 'handles' => [ + 'items' => 'con elementos', + 'no_items' => 'sin elementos', + ], ], 'config' => [ 'select_placeholder' => 'Selecciona…', + 'invalid_json' => 'Esto aún no es un JSON válido.', 'trigger' => [ 'type' => 'Tipo de disparador', @@ -146,14 +154,10 @@ 'generate' => [ 'social_accounts' => 'Cuentas sociales', 'social_accounts_empty' => 'Sin cuentas sociales conectadas. Conecta una primero.', - 'target_slide_count' => 'Diapositivas a generar (para plataformas con carrusel)', + 'target_slide_count' => 'Diapositivas a generar', 'prompt_template' => 'Plantilla de prompt', - 'image_source' => 'Fuente de imagen', - 'image_sources' => [ - 'ai' => 'Generada con IA', - 'unsplash' => 'Unsplash', - 'none' => 'Sin imagen', - ], + 'include_image' => 'Incluir imagen', + 'include_image_hint' => 'Generar una imagen con IA para esta publicación', ], 'delay' => [ 'duration' => 'Duración', @@ -216,6 +220,10 @@ 'api_key_header' => 'Nombre del header', 'api_key_value' => 'API key', 'body_template' => 'Plantilla del body (JSON)', + 'headers' => 'Headers', + 'header_name' => 'Nombre del header', + 'header_value' => 'Valor', + 'add_header' => 'Agregar header', 'polling_section' => 'Polling (opcional)', 'polling_hint' => 'Deja vacío para usar la respuesta completa como un solo payload. Rellena para extraer un array de ítems y disparar un run por ítem.', 'items_path' => 'Ruta de ítems', @@ -244,6 +252,7 @@ 'only_failed_can_retry' => 'Solo se pueden reintentar ejecuciones fallidas.', 'no_generated_post' => 'No se encontró un post generado en la ejecución.', 'webhook_server_error' => 'Error del servidor del webhook.', + 'webhook_invalid_payload_json' => 'La plantilla de payload no es un JSON válido.', 'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.', 'no_trigger_connection' => 'Ningún nodo está conectado al nodo disparador.', ], diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index 828398e9..bc116fdd 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -31,8 +31,11 @@ 'node_input' => 'Entrada', 'node_output' => 'Saída', 'node_error' => 'Erro', + 'no_new_items' => 'Nenhum item novo — nada foi executado adiante.', 'error_starting' => 'Não foi possível iniciar a execução de teste.', 'with_real_data' => 'Com dados reais', + 'run' => 'Rodar teste', + 'idle_hint' => 'Clique em Rodar teste para executar a automação de ponta a ponta.', 'real_data_hint' => 'Este teste vai publicar posts, avançar watermarks e disparar efeitos colaterais externos.', 'dry_badge' => 'Teste seco', ], @@ -90,10 +93,15 @@ 'end_summary' => 'Encerra a automação aqui', 'fetch_rss' => 'Buscar RSS', 'http_request' => 'Requisição HTTP', + 'handles' => [ + 'items' => 'tem itens', + 'no_items' => 'sem itens', + ], ], 'config' => [ 'select_placeholder' => 'Selecione…', + 'invalid_json' => 'Isto ainda não é um JSON válido.', 'trigger' => [ 'type' => 'Tipo de trigger', @@ -146,14 +154,10 @@ 'generate' => [ 'social_accounts' => 'Contas sociais', 'social_accounts_empty' => 'Nenhuma conta social conectada. Conecte uma primeiro.', - 'target_slide_count' => 'Slides a gerar (para plataformas com carrossel)', + 'target_slide_count' => 'Slides a gerar', 'prompt_template' => 'Template do prompt', - 'image_source' => 'Origem da imagem', - 'image_sources' => [ - 'ai' => 'Gerada por IA', - 'unsplash' => 'Unsplash', - 'none' => 'Sem imagem', - ], + 'include_image' => 'Incluir imagem', + 'include_image_hint' => 'Gerar uma imagem com IA para esta publicação', ], 'delay' => [ 'duration' => 'Duração', @@ -216,6 +220,10 @@ 'api_key_header' => 'Nome do header', 'api_key_value' => 'API key', 'body_template' => 'Template do body (JSON)', + 'headers' => 'Headers', + 'header_name' => 'Nome do header', + 'header_value' => 'Valor', + 'add_header' => 'Adicionar header', 'polling_section' => 'Polling (opcional)', 'polling_hint' => 'Deixe vazio para usar a resposta inteira como payload único. Preencha para extrair um array de itens e disparar um run por item.', 'items_path' => 'Caminho dos itens', @@ -244,6 +252,7 @@ 'only_failed_can_retry' => 'Apenas execuções que falharam podem ser repetidas.', 'no_generated_post' => 'Nenhum post gerado encontrado para esta execução.', 'webhook_server_error' => 'Erro no servidor do webhook.', + 'webhook_invalid_payload_json' => 'O template do payload não é um JSON válido.', 'node_no_longer_exists' => 'O nó :node_id não existe mais nesta automação.', 'no_trigger_connection' => 'Nenhum nó conectado ao nó de trigger.', ], diff --git a/package-lock.json b/package-lock.json index 3d94a694..58bfa129 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,10 @@ "packages": { "": { "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.17", "@inertiajs/vue3": "^3.0.0", "@tabler/icons-vue": "^3.36.1", "@tailwindcss/typography": "^0.5.19", @@ -16,6 +20,7 @@ "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "dayjs": "^1.11.19", "embla-carousel-vue": "^8.6.0", "highlight.js": "^11.11.1", @@ -214,6 +219,97 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", + "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -1188,6 +1284,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", @@ -1259,6 +1390,12 @@ "node": ">=6.0.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4333,6 +4470,21 @@ "node": ">=6" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4452,6 +4604,12 @@ "node": ">= 6" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9141,6 +9299,12 @@ "dev": true, "license": "MIT" }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -9947,6 +10111,12 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/web-vitals": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz", diff --git a/package.json b/package.json index 0854b3c7..8486bf4f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,10 @@ "vue-tsc": "^2.2.4" }, "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.17", "@inertiajs/vue3": "^3.0.0", "@tabler/icons-vue": "^3.36.1", "@tailwindcss/typography": "^0.5.19", @@ -49,6 +53,7 @@ "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "dayjs": "^1.11.19", "embla-carousel-vue": "^8.6.0", "highlight.js": "^11.11.1", diff --git a/resources/css/app.css b/resources/css/app.css index 9c4fe736..3e4f3653 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -171,6 +171,7 @@ .trypost-container { .automation-node { position: relative; min-width: 230px; + max-width: 260px; background: var(--card); border: 2px solid var(--foreground); border-radius: 14px; @@ -185,6 +186,7 @@ .automation-node:hover { .automation-node--wide { min-width: 260px; + max-width: 260px; } .automation-node.is-selected { @@ -233,11 +235,15 @@ .automation-node--accent-slate .automation-node__header { background: #f1f5f9; .automation-node--accent-zinc .automation-node__header { background: #f4f4f5; } .automation-node__title { + min-width: 0; font-weight: 700; font-size: 0.875rem; color: var(--foreground); line-height: 1.2; letter-spacing: -0.005em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .automation-node__summary { diff --git a/resources/js/components/CodeEditor.vue b/resources/js/components/CodeEditor.vue new file mode 100644 index 00000000..aa357dd4 --- /dev/null +++ b/resources/js/components/CodeEditor.vue @@ -0,0 +1,159 @@ + + + diff --git a/resources/js/components/automations/TestRunPanel.vue b/resources/js/components/automations/TestRunPanel.vue index fa42b65e..7c998d6d 100644 --- a/resources/js/components/automations/TestRunPanel.vue +++ b/resources/js/components/automations/TestRunPanel.vue @@ -1,5 +1,5 @@ -
- - +
+ +
+ +
+
+ +
+
+ +

{{ $t('automations.config.generate.include_image_hint') }}

+
+
@@ -318,20 +331,5 @@ const hasCarouselCapableAccount = computed(() =>