Merge pull request #127 from dantaspaulo/feat/ai-prompt-char-counter
feat(ai-create): show character counter under the AI prompt field
This commit is contained in:
commit
450b6fd3de
6 changed files with 120 additions and 3 deletions
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Http\Requests\App\Ai;
|
||||
|
||||
use App\Support\AiPromptRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GeneratePostContentRequest extends FormRequest
|
||||
|
|
@ -19,7 +20,7 @@ public function authorize(): bool
|
|||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'prompt' => ['required', 'string', 'max:2000'],
|
||||
'prompt' => ['required', 'string', 'max:'.AiPromptRules::PROMPT_MAX_LENGTH],
|
||||
'current_content' => ['nullable', 'string', 'max:10000'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Support\AiPromptRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
|
@ -33,7 +34,7 @@ public function rules(): array
|
|||
],
|
||||
'social_account_id' => ['nullable', 'uuid'],
|
||||
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
|
||||
'prompt' => ['required', 'string', 'max:2000'],
|
||||
'prompt' => AiPromptRules::wizardPromptRule(),
|
||||
'date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
|
||||
];
|
||||
|
|
|
|||
36
app/Support/AiPromptRules.php
Normal file
36
app/Support/AiPromptRules.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Single source of truth for the user-supplied AI generation prompt length,
|
||||
* shared by the create wizard (StartPostCreationRequest) and the editor's
|
||||
* content generation (GeneratePostContentRequest). The frontend counter in
|
||||
* resources/js/components/posts/create/AiPostWizard.vue mirrors these bounds.
|
||||
*/
|
||||
class AiPromptRules
|
||||
{
|
||||
/**
|
||||
* Minimum prompt length (characters) for the create wizard.
|
||||
*/
|
||||
public const PROMPT_MIN_LENGTH = 3;
|
||||
|
||||
/**
|
||||
* Maximum prompt length (characters); mirrored by the frontend counter.
|
||||
*/
|
||||
public const PROMPT_MAX_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Validation rules for the create wizard's generation prompt. The editor's
|
||||
* content generation reuses only PROMPT_MAX_LENGTH — it has no minimum,
|
||||
* since it has no character counter to mirror one.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function wizardPromptRule(): array
|
||||
{
|
||||
return ['required', 'string', 'min:'.self::PROMPT_MIN_LENGTH, 'max:'.self::PROMPT_MAX_LENGTH];
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,8 @@ const selectedAccountId = ref<string | null>(null);
|
|||
const includeImages = ref(true);
|
||||
const imageCount = ref(2);
|
||||
const promptText = ref('');
|
||||
const PROMPT_MIN = 3;
|
||||
const PROMPT_MAX = 2000;
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
|
|
@ -157,10 +159,13 @@ const submittedImageCount = computed(() => {
|
|||
return 0;
|
||||
});
|
||||
|
||||
const promptLength = computed(() => [...promptText.value.trim()].length);
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
selectedFormat.value !== null &&
|
||||
selectedAccountId.value !== null &&
|
||||
promptText.value.trim().length >= 3,
|
||||
promptLength.value >= PROMPT_MIN &&
|
||||
promptLength.value <= PROMPT_MAX,
|
||||
);
|
||||
|
||||
// Auto-pick the only account when format has exactly one match.
|
||||
|
|
@ -358,6 +363,14 @@ const startGeneration = async () => {
|
|||
:placeholder="$t('posts.create.steps.prompt_placeholder')"
|
||||
class="min-h-[140px] resize-none"
|
||||
/>
|
||||
<p
|
||||
data-testid="ai-prompt-counter"
|
||||
aria-live="polite"
|
||||
class="text-right text-xs tabular-nums"
|
||||
:class="promptLength > PROMPT_MAX ? 'font-semibold text-destructive' : 'text-muted-foreground'"
|
||||
>
|
||||
{{ promptLength }}/{{ PROMPT_MAX }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Generate -->
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\AiPromptRules;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
|
|
@ -32,6 +33,57 @@
|
|||
->assertJsonValidationErrors(['prompt']);
|
||||
});
|
||||
|
||||
test('start rejects a prompt shorter than the minimum length', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create'), ['prompt' => 'hi', 'format' => 'x_post'])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJsonValidationErrors(['prompt']);
|
||||
|
||||
Bus::assertNotDispatched(StreamPostCreation::class);
|
||||
});
|
||||
|
||||
test('start accepts a prompt at the minimum length', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create'), [
|
||||
'prompt' => str_repeat('a', AiPromptRules::PROMPT_MIN_LENGTH),
|
||||
'format' => 'x_post',
|
||||
])
|
||||
->assertStatus(Response::HTTP_ACCEPTED);
|
||||
|
||||
Bus::assertDispatched(StreamPostCreation::class);
|
||||
});
|
||||
|
||||
test('start rejects a prompt longer than the maximum length', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create'), [
|
||||
'prompt' => str_repeat('a', AiPromptRules::PROMPT_MAX_LENGTH + 1),
|
||||
'format' => 'x_post',
|
||||
])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJsonValidationErrors(['prompt']);
|
||||
|
||||
Bus::assertNotDispatched(StreamPostCreation::class);
|
||||
});
|
||||
|
||||
test('start accepts a prompt at the maximum length', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.create'), [
|
||||
'prompt' => str_repeat('a', AiPromptRules::PROMPT_MAX_LENGTH),
|
||||
'format' => 'x_post',
|
||||
])
|
||||
->assertStatus(Response::HTTP_ACCEPTED);
|
||||
|
||||
Bus::assertDispatched(StreamPostCreation::class);
|
||||
});
|
||||
|
||||
test('start validates format is required', function () {
|
||||
Bus::fake();
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\AiPromptRules;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
|
|
@ -34,6 +35,19 @@
|
|||
->assertJsonValidationErrors(['prompt']);
|
||||
});
|
||||
|
||||
test('endpoint rejects a prompt longer than the maximum length', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson(route('app.posts.ai.generate', $this->post), [
|
||||
'prompt' => str_repeat('a', AiPromptRules::PROMPT_MAX_LENGTH + 1),
|
||||
])
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJsonValidationErrors(['prompt']);
|
||||
|
||||
Bus::assertNotDispatched(StreamPostContent::class);
|
||||
});
|
||||
|
||||
test('endpoint blocks access to other workspace posts', function () {
|
||||
Bus::fake();
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
|
|
|
|||
Loading…
Reference in a new issue