From 7fdb003d4bb34ce80d3ca6f31b8fea8b62832d96 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Sun, 25 Oct 2026 15:59:07 +0100 Subject: [PATCH] enhance(ai): add selectable caption writing styles --- .env.example | 5 ++ app/Actions/Ai/AssistPostContent.php | 3 +- app/Ai/Agents/PostWritingAssistant.php | 2 + .../App/PostAiAssistantController.php | 3 + .../Middleware/App/HandleInertiaRequests.php | 2 + .../App/Ai/AssistPostContentRequest.php | 1 + app/Support/Ai/WritingStyleCatalog.php | 61 +++++++++++++ config/ai_writing.php | 7 ++ lang/ar/posts.php | 4 + lang/de/posts.php | 4 + lang/el/posts.php | 4 + lang/en/posts.php | 4 + lang/es/posts.php | 4 + lang/fr/posts.php | 4 + lang/it/posts.php | 4 + lang/ja/posts.php | 4 + lang/ko/posts.php | 4 + lang/nl/posts.php | 4 + lang/pl/posts.php | 4 + lang/pt-BR/posts.php | 4 + lang/ru/posts.php | 4 + lang/tr/posts.php | 4 + lang/uk/posts.php | 4 + lang/zh/posts.php | 4 + .../components/ai/WritingAssistantPanel.vue | 53 +++++++++-- resources/js/types/index.d.ts | 1 + .../prompts/post_content/assistant.blade.php | 12 +++ tests/Browser/ComposerTemplatesTest.php | 50 +++++++---- tests/Feature/Ai/AiModelRoutingTest.php | 32 +++++-- tests/Feature/Ai/PostAiAssistantTest.php | 89 ++++++++++++++++--- .../Ai/Agents/PostWritingAssistantTest.php | 65 ++++---------- 31 files changed, 358 insertions(+), 92 deletions(-) create mode 100644 app/Support/Ai/WritingStyleCatalog.php create mode 100644 config/ai_writing.php diff --git a/.env.example b/.env.example index 8d56727ee6..fbb16c0c89 100644 --- a/.env.example +++ b/.env.example @@ -218,6 +218,11 @@ ELEVENLABS_API_KEY= AI_TEXT_PROVIDER=openai AI_AUDIO_PROVIDER=elevenlabs +# Optional DF caption style. Default uses natural-writing guidance without this file. +# Set an absolute path to a readable UTF-8 guide of at most 8 KiB to offer DF. +# The guide is sent only when DF is selected; keep it suitable for the chosen provider. +# AI_DF_STYLE_PATH=/absolute/path/to/writing-style-df.md + # AI Model Overrides (optional) # Each provider picks a sensible default model per capability on its own - # these are NOT shared across capabilities, so overriding one (e.g. a text diff --git a/app/Actions/Ai/AssistPostContent.php b/app/Actions/Ai/AssistPostContent.php index 8173b52f2a..d215f781f3 100644 --- a/app/Actions/Ai/AssistPostContent.php +++ b/app/Actions/Ai/AssistPostContent.php @@ -25,11 +25,12 @@ public static function execute( ?Platform $platform, array $selection, ?SocialAccount $account = null, + ?string $writingStyleInstructions = null, ): string { $platform = $account->platform ?? $platform; $limit = $account?->maxContentLength() ?? $platform?->maxContentLength(); - $response = (new PostWritingAssistant($mode, $currentContent, $user->locale, $platform, $previousContent, $limit)) + $response = (new PostWritingAssistant($mode, $currentContent, $user->locale, $platform, $previousContent, $limit, $writingStyleInstructions)) ->withModelSelection($selection) ->prompt( $mode->requiresPrompt() ? trim((string) $prompt) : 'Revise the existing caption as instructed.', diff --git a/app/Ai/Agents/PostWritingAssistant.php b/app/Ai/Agents/PostWritingAssistant.php index 6923bf32f0..e37e70e0f8 100644 --- a/app/Ai/Agents/PostWritingAssistant.php +++ b/app/Ai/Agents/PostWritingAssistant.php @@ -26,12 +26,14 @@ public function __construct( public ?Platform $platform = null, public ?string $previousContent = null, public ?int $hardMaxChars = null, + public readonly ?string $writingStyleInstructions = null, ) {} public function instructions(): string { return view('prompts.post_content.assistant', [ 'mode' => $this->mode->value, + 'writing_style_instructions' => $this->writingStyleInstructions, 'writes_new_text' => $this->mode->requiresPrompt(), 'language' => $this->locale->promptLanguage(), 'current_content' => $this->currentContent, diff --git a/app/Http/Controllers/App/PostAiAssistantController.php b/app/Http/Controllers/App/PostAiAssistantController.php index 014aeae433..c6276d02cb 100644 --- a/app/Http/Controllers/App/PostAiAssistantController.php +++ b/app/Http/Controllers/App/PostAiAssistantController.php @@ -9,6 +9,7 @@ use App\Enums\SocialAccount\Platform; use App\Http\Requests\App\Ai\AssistPostContentRequest; use App\Support\Ai\AiModelCatalog; +use App\Support\Ai\WritingStyleCatalog; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Http\JsonResponse; @@ -30,6 +31,7 @@ public function __invoke(AssistPostContentRequest $request): JsonResponse } $selection = app(AiModelCatalog::class)->resolve($request->validated('model_id')); + $writingStyleInstructions = app(WritingStyleCatalog::class)->resolve($request->validated('style_id')); try { $content = AssistPostContent::execute( @@ -41,6 +43,7 @@ public function __invoke(AssistPostContentRequest $request): JsonResponse previousContent: $request->input('previous_content'), platform: $request->enum('platform', Platform::class), selection: $selection, + writingStyleInstructions: $writingStyleInstructions, account: $request->filled('social_account_id') ? $workspace->socialAccounts()->find($request->input('social_account_id')) : null, diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 41f6bf714f..04d01cd8f1 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -21,6 +21,7 @@ use App\Models\Plan; use App\Models\Workspace; use App\Support\Ai\AiModelCatalog; +use App\Support\Ai\WritingStyleCatalog; use App\Support\HeicConverter; use App\Support\LinkTlds; use Illuminate\Http\Request; @@ -111,6 +112,7 @@ public function shareOnce(Request $request): array 'githubAuthEnabled' => fn (): bool => SocialAuthProvider::GitHub->isEnabled(), ...($user !== null ? [ 'aiModels' => fn (): array => app(AiModelCatalog::class)->forFrontend(), + 'aiWritingStyles' => fn (): array => app(WritingStyleCatalog::class)->forFrontend(), 'mediaSources' => fn (): array => ['menu' => MediaSource::menu()], 'mediaUploadLimits' => fn (): array => [ 'max_bytes' => [ diff --git a/app/Http/Requests/App/Ai/AssistPostContentRequest.php b/app/Http/Requests/App/Ai/AssistPostContentRequest.php index b324f26d62..7252d8b483 100644 --- a/app/Http/Requests/App/Ai/AssistPostContentRequest.php +++ b/app/Http/Requests/App/Ai/AssistPostContentRequest.php @@ -28,6 +28,7 @@ public function rules(): array return [ 'model_id' => ['nullable', 'string', 'max:64'], + 'style_id' => ['nullable', 'string', 'max:64'], 'mode' => ['required', Rule::enum(PostAssistantMode::class)], 'prompt' => [ Rule::requiredIf(fn (): bool => $mode?->requiresPrompt() ?? false), diff --git a/app/Support/Ai/WritingStyleCatalog.php b/app/Support/Ai/WritingStyleCatalog.php new file mode 100644 index 0000000000..44075a2fec --- /dev/null +++ b/app/Support/Ai/WritingStyleCatalog.php @@ -0,0 +1,61 @@ +unavailable(); + } + + $resolved = realpath($path); + if ($resolved === false || ! is_file($resolved) || ! is_readable($resolved)) { + $this->unavailable(); + } + + $handle = @fopen($resolved, 'rb'); + if ($handle === false) { + $this->unavailable(); + } + + try { + $instructions = stream_get_contents($handle, 8193); + } finally { + fclose($handle); + } + + if ($instructions === false || strlen($instructions) > 8192 + || trim($instructions) === '' || ! mb_check_encoding($instructions, 'UTF-8') + || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $instructions)) { + $this->unavailable(); + } + + return trim($instructions); + } + + /** @return array{default: string, options: list} */ + public function forFrontend(): array + { + return [ + 'default' => 'default', + 'options' => filled(config('ai_writing.df_style_path')) ? ['default', 'df'] : ['default'], + ]; + } + + private function unavailable(): never + { + throw ValidationException::withMessages(['style_id' => __('posts.composer.ai_style_unavailable')]); + } +} diff --git a/config/ai_writing.php b/config/ai_writing.php new file mode 100644 index 0000000000..b9ed970135 --- /dev/null +++ b/config/ai_writing.php @@ -0,0 +1,7 @@ + env('AI_DF_STYLE_PATH'), +]; diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 234be8bb3d..cc78b1fc90 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'النص المقترح', 'assistant_back' => 'رجوع', 'assistant_error' => 'تعذر إنشاء اقتراح. حاول مرة أخرى.', + 'ai_style' => 'الأسلوب', + 'ai_style_default' => 'الافتراضي', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'هذا الأسلوب غير متاح. اختر الافتراضي أو حاول لاحقًا.', 'ai_model' => 'نموذج الذكاء الاصطناعي', 'ai_model_unavailable_option' => 'النموذج غير متاح', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/de/posts.php b/lang/de/posts.php index 6ad58f5efe..6df38a7136 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -105,6 +105,10 @@ 'assistant_suggestion' => 'Vorgeschlagener Text', 'assistant_back' => 'Zurück', 'assistant_error' => 'Der Vorschlag konnte nicht erstellt werden. Bitte versuche es erneut.', + 'ai_style' => 'Stil', + 'ai_style_default' => 'Standard', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Dieser Schreibstil ist nicht verfügbar. Wähle Standard oder versuche es später erneut.', 'ai_model' => 'KI-Modell', 'ai_model_unavailable_option' => 'Modell nicht verfügbar', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/el/posts.php b/lang/el/posts.php index 13c7f30bf7..69956c7067 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Προτεινόμενο κείμενο', 'assistant_back' => 'Πίσω', 'assistant_error' => 'Δεν ήταν δυνατή η δημιουργία πρότασης. Δοκιμάστε ξανά.', + 'ai_style' => 'Ύφος', + 'ai_style_default' => 'Προεπιλογή', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Αυτό το ύφος δεν είναι διαθέσιμο. Επιλέξτε το προεπιλεγμένο ή δοκιμάστε ξανά αργότερα.', 'ai_model' => 'Μοντέλο AI', 'ai_model_unavailable_option' => 'Μη διαθέσιμο μοντέλο', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/en/posts.php b/lang/en/posts.php index ba8b792124..e2f926db82 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Suggested caption', 'assistant_back' => 'Back', 'assistant_error' => 'Could not generate a suggestion. Please try again.', + 'ai_style' => 'Style', + 'ai_style_default' => 'Default', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'This writing style is unavailable. Choose Default or try again later.', 'ai_model' => 'AI model', 'ai_model_unavailable_option' => 'Unavailable model', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/es/posts.php b/lang/es/posts.php index 4beac114fa..63b8274ebc 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Texto sugerido', 'assistant_back' => 'Volver', 'assistant_error' => 'No se pudo generar una sugerencia. Inténtalo de nuevo.', + 'ai_style' => 'Estilo', + 'ai_style_default' => 'Predeterminado', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Este estilo no está disponible. Elige Predeterminado o inténtalo más tarde.', 'ai_model' => 'Modelo de IA', 'ai_model_unavailable_option' => 'Modelo no disponible', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index ff89d16f78..f6f3d5f04c 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Texte proposé', 'assistant_back' => 'Retour', 'assistant_error' => 'Impossible de générer une suggestion. Veuillez réessayer.', + 'ai_style' => 'Style', + 'ai_style_default' => 'Par défaut', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Ce style est indisponible. Choisissez le style par défaut ou réessayez plus tard.', 'ai_model' => 'Modèle IA', 'ai_model_unavailable_option' => 'Modèle indisponible', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/it/posts.php b/lang/it/posts.php index df3a25a0c1..e13447c31d 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Testo suggerito', 'assistant_back' => 'Indietro', 'assistant_error' => 'Impossibile generare un suggerimento. Riprova.', + 'ai_style' => 'Stile', + 'ai_style_default' => 'Predefinito', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Questo stile non è disponibile. Scegli Predefinito o riprova più tardi.', 'ai_model' => 'Modello IA', 'ai_model_unavailable_option' => 'Modello non disponibile', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 61affc0db7..87c06cbde8 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => '提案された文章', 'assistant_back' => '戻る', 'assistant_error' => '提案を生成できませんでした。もう一度お試しください。', + 'ai_style' => 'スタイル', + 'ai_style_default' => '標準', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'この文体は利用できません。標準を選ぶか、後でお試しください。', 'ai_model' => 'AIモデル', 'ai_model_unavailable_option' => '利用できないモデル', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 8fbf881236..d3d08d22b0 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => '제안된 문구', 'assistant_back' => '뒤로', 'assistant_error' => '제안을 생성하지 못했습니다. 다시 시도해 주세요.', + 'ai_style' => '스타일', + 'ai_style_default' => '기본', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => '이 문체는 사용할 수 없습니다. 기본을 선택하거나 나중에 다시 시도하세요.', 'ai_model' => 'AI 모델', 'ai_model_unavailable_option' => '사용할 수 없는 모델', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 980920dc65..746c639c48 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Voorgestelde tekst', 'assistant_back' => 'Terug', 'assistant_error' => 'Er kon geen voorstel worden gemaakt. Probeer het opnieuw.', + 'ai_style' => 'Stijl', + 'ai_style_default' => 'Standaard', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Deze schrijfstijl is niet beschikbaar. Kies Standaard of probeer het later opnieuw.', 'ai_model' => 'AI-model', 'ai_model_unavailable_option' => 'Model niet beschikbaar', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 9a5b7943bb..37e7e03de5 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Sugerowana treść', 'assistant_back' => 'Wróć', 'assistant_error' => 'Nie udało się wygenerować propozycji. Spróbuj ponownie.', + 'ai_style' => 'Styl', + 'ai_style_default' => 'Domyślny', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Ten styl jest niedostępny. Wybierz Domyślny lub spróbuj ponownie później.', 'ai_model' => 'Model AI', 'ai_model_unavailable_option' => 'Model niedostępny', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index c8d01ff5f3..e35ebe2457 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Legenda sugerida', 'assistant_back' => 'Voltar', 'assistant_error' => 'Não foi possível gerar uma sugestão. Tente novamente.', + 'ai_style' => 'Estilo', + 'ai_style_default' => 'Padrão', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Este estilo está indisponível. Escolha Padrão ou tente novamente mais tarde.', 'ai_model' => 'Modelo de IA', 'ai_model_unavailable_option' => 'Modelo indisponível', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index c6c0584379..e6c33837fd 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Предложенный текст', 'assistant_back' => 'Назад', 'assistant_error' => 'Не удалось создать предложение. Попробуйте ещё раз.', + 'ai_style' => 'Стиль', + 'ai_style_default' => 'По умолчанию', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Этот стиль недоступен. Выберите стиль по умолчанию или повторите попытку позже.', 'ai_model' => 'Модель ИИ', 'ai_model_unavailable_option' => 'Модель недоступна', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 3632ac955f..cd7b0ea8cd 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -105,6 +105,10 @@ 'assistant_suggestion' => 'Önerilen metin', 'assistant_back' => 'Geri', 'assistant_error' => 'Öneri oluşturulamadı. Lütfen tekrar deneyin.', + 'ai_style' => 'Stil', + 'ai_style_default' => 'Varsayılan', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Bu yazım stili kullanılamıyor. Varsayılanı seçin veya daha sonra tekrar deneyin.', 'ai_model' => 'Yapay zekâ modeli', 'ai_model_unavailable_option' => 'Model kullanılamıyor', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 34a0a2b82b..db16f55697 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => 'Запропонований текст', 'assistant_back' => 'Назад', 'assistant_error' => 'Не вдалося створити пропозицію. Спробуйте ще раз.', + 'ai_style' => 'Стиль', + 'ai_style_default' => 'Типовий', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => 'Цей стиль недоступний. Виберіть типовий стиль або повторіть спробу пізніше.', 'ai_model' => 'Модель ШІ', 'ai_model_unavailable_option' => 'Модель недоступна', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index a4e858fc8b..e16f0d496b 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -103,6 +103,10 @@ 'assistant_suggestion' => '建议文案', 'assistant_back' => '返回', 'assistant_error' => '无法生成建议,请重试。', + 'ai_style' => '风格', + 'ai_style_default' => '默认', + 'ai_style_df' => 'DF', + 'ai_style_unavailable' => '此写作风格不可用。请选择默认风格或稍后重试。', 'ai_model' => 'AI 模型', 'ai_model_unavailable_option' => '模型不可用', 'ai_model_destination_azure' => 'UZH Azure', diff --git a/resources/js/components/ai/WritingAssistantPanel.vue b/resources/js/components/ai/WritingAssistantPanel.vue index 36b8ee5283..383f306bd7 100644 --- a/resources/js/components/ai/WritingAssistantPanel.vue +++ b/resources/js/components/ai/WritingAssistantPanel.vue @@ -12,6 +12,13 @@ import { computed, onBeforeUnmount, ref } from 'vue'; import AiModelSelect from '@/components/ai/AiModelSelect.vue'; import CharacterCounter from '@/components/CharacterCounter.vue'; import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { extractErrorMessage } from '@/lib/httpError'; import { @@ -68,6 +75,7 @@ const pendingMode = ref('generate'); const http = useHttp({ model_id: (page.props.aiModels?.default ?? null) as string | null, + style_id: page.props.aiWritingStyles?.default ?? 'default', mode: '', prompt: '', current_content: '', @@ -80,6 +88,10 @@ onBeforeUnmount(() => { http.cancel(); }); +const styleOptions = computed( + () => page.props.aiWritingStyles?.options ?? ['default'], +); + const spinnerLabel = computed(() => REWRITE_MODES.includes(pendingMode.value as RewriteMode) ? trans(`posts.composer.assistant_modes.${pendingMode.value}`) @@ -182,11 +194,42 @@ const accept = (): void => { class="flex min-h-full flex-col gap-4" data-testid="writing-assistant-panel" > - +
+ +
+ + +
+