trypost/resources/js/components/posts/ai/AiGenerateDialog.vue
Paulo Castellano a1a9172448
fix: subscribe to AI generation channel before dispatching the job (#269)
* fix: subscribe to AI generation channel before dispatching the job

AiGenerateDialog posted the generate request and only subscribed to the
broadcast channel once the response came back. Reverb's plain Channel
(vendor/laravel/reverb/.../Channels/Channel.php) delivers broadcasts by
iterating the in-memory connection list at send time only — no history,
no replay. If the queued job started streaming text_delta/stream_end
events before the private-channel subscribe handshake finished, those
events were gone for good and the dialog hung on 'streaming' forever.

Generate the generation_id client-side, await channel subscription
confirmation (Echo's channel.subscribed(), backed by
pusher:subscription_succeeded) before sending the generate request, and
have the backend use the client-supplied id instead of minting its own.

Related to #218.

* fix: handle subscribe failure, dialog-close race, and i18n hardcoded strings

Code review on the previous commit surfaced real gaps:

- useAiStream only handled the subscribe-success path (.subscribed()).
  A definitive pusher:subscription_error (expired session, CSRF
  mismatch) fell through to the 5s timeout and was treated as success,
  reproducing the exact #218 hang via a different trigger. subscribe()
  now returns a boolean: true on confirm-or-ambiguous-timeout (proceed
  optimistically), false only on an explicit error (don't dispatch work
  nothing will ever hear).
- AiGenerateDialog now bails without dispatching if the dialog is
  closed while awaiting subscription confirmation (up to 5s), and
  unsubscribes on a post-subscribe dispatch failure so stale listeners
  can't flip status to 'completed' after an error was already shown.
- Extracted aiGenerationChannel() so the frontend has one place to
  update if the channel format changes, and cross-referenced the two
  remaining backend copies (PostAiGenerateController, StreamPostContent).
- Replaced the two hardcoded English fallback error strings with i18n
  keys (posts.ai.generate.errors.*) across all 16 locales — this app
  only ships translated copy.
- Simplified subscribe(): dropped a redundant "already settled" guard
  and the clearTimeout bookkeeping — Promise.resolve() already no-ops
  after the first call, so there was nothing to guard.

* fix: subscribe before dispatch in AI post creation and image regeneration flows

Extracts subscribePrivateChannel as a shared helper and applies the same
subscribe-before-dispatch fix from the post edit flow (issue #218) to the
other two flows that stream over private channels.

Post creation wizard moves the StreamPostCreation dispatch out of
AiPostWizard.vue and into Loading.vue, so the channel is subscribed before
the job that broadcasts on it is dispatched — previously a full page
navigation happened between dispatch and subscribe with no timeout
fallback, so a lost event hung the page forever.

* refactor: trim explanatory comments added in the subscribe-before-dispatch fix

Rationale lives in the commit history, not scattered inline comments.

* refactor: trim remaining explanatory comments from the subscribe-before-dispatch fix

Same cleanup as the previous commit, applied to the files from the
original post-edit-flow fix.

* fix: dedup post-creation dispatch, surface field-level AI errors, harden edge cases

Uses ShouldBeUnique on StreamPostCreation (scoped per-user) instead of an
ad-hoc cache guard, matching the pattern already used by
PublishToSocialPlatform and VerifyUpcomingPostConnections.

Field-level validation errors (prompt/instruction) now render inline via
InputError, matching how every other form in the app surfaces them, instead
of a generic status banner. Also fixes a 422 response missing the `errors`
key that Inertia's client silently swallows, adds error handling around the
wizard's navigation to the loading page, and extracts the duplicated
error-message parsing into a shared helper.
2026-08-10 16:06:37 -03:00

169 lines
6 KiB
Vue

<script setup lang="ts">
import { useHttp, usePage } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed, onUnmounted, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { aiGenerationChannel, useAiStream } from '@/composables/echo/useAiStream';
import { generate as generatePostAi } from '@/routes/app/posts/ai';
const props = defineProps<{
postId: string;
currentContent: string;
}>();
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
(e: 'apply', content: string): void;
}>();
const page = usePage();
const prompt = ref('');
const dispatching = ref(false);
const promptError = ref<string | undefined>(undefined);
const { text, status, errorMessage, subscribe, unsubscribe, reset } = useAiStream();
const httpGenerate = useHttp<{ prompt: string; current_content: string | null; generation_id: string }>({
prompt: '',
current_content: null,
generation_id: '',
});
let unmounted = false;
onUnmounted(() => {
unmounted = true;
});
const startGeneration = async () => {
if (! prompt.value.trim()) return;
dispatching.value = true;
promptError.value = undefined;
const generationId = crypto.randomUUID();
const channel = aiGenerationChannel(String(page.props.auth.user.id), generationId);
try {
const subscribed = await subscribe(channel);
if (unmounted || ! open.value) {
unsubscribe();
return;
}
if (! subscribed) {
throw new Error('Channel subscription failed');
}
httpGenerate.prompt = prompt.value;
httpGenerate.current_content = props.currentContent || null;
httpGenerate.generation_id = generationId;
await httpGenerate.post(generatePostAi.url(props.postId));
if (httpGenerate.hasErrors) {
unsubscribe();
reset();
promptError.value = httpGenerate.errors.prompt ?? trans('posts.ai.generate.errors.start_failed');
return;
}
} catch {
unsubscribe();
status.value = 'failed';
errorMessage.value = trans('posts.ai.generate.errors.start_failed');
} finally {
dispatching.value = false;
}
};
// The streamer agent shares the structured-output prompt template with
// PostContentGenerator, so what comes through the stream is a JSON object
// like {"content": "...", "image_title": "...", ...}. We only want the
// content field — and only once the JSON is complete (mid-stream the parse
// fails and we show nothing, avoiding the typewriter-of-JSON effect).
const previewText = computed(() => {
if (! text.value) return '';
try {
const parsed = JSON.parse(text.value);
if (parsed && typeof parsed.content === 'string') return parsed.content;
} catch {
// Mid-stream the JSON is incomplete — leave preview empty.
}
return '';
});
const apply = () => {
emit('apply', previewText.value);
open.value = false;
};
const retry = () => {
unsubscribe();
reset();
startGeneration();
};
const canApply = computed(() => status.value === 'completed' && previewText.value.trim().length > 0);
const canRetry = computed(() => status.value === 'completed' || status.value === 'failed');
watch(open, () => {
unsubscribe();
reset();
prompt.value = '';
promptError.value = undefined;
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{{ $t('posts.ai.generate.title') }}</DialogTitle>
<DialogDescription>{{ $t('posts.ai.generate.description') }}</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="grid gap-2">
<Label for="ai-generate-prompt">{{ $t('posts.ai.generate.prompt_label') }}</Label>
<Textarea
id="ai-generate-prompt"
v-model="prompt"
:placeholder="$t('posts.ai.generate.prompt_placeholder')"
:disabled="status === 'streaming'"
rows="3"
/>
<InputError :message="promptError" />
</div>
<div v-if="status !== 'idle'" class="grid gap-2">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.ai.generate.preview_label') }}</Label>
<div class="min-h-[120px] whitespace-pre-wrap break-words rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ previewText || '...' }}</div>
<p v-if="status === 'failed'" class="text-xs font-semibold text-rose-700">{{ errorMessage }}</p>
</div>
</div>
<DialogFooter>
<Button
v-if="status === 'idle' || status === 'streaming'"
:loading="dispatching || status === 'streaming'"
:disabled="! prompt.trim()"
@click="startGeneration"
>
{{ $t('posts.ai.generate.start') }}
</Button>
<Button v-if="canApply" @click="apply">
{{ $t('posts.ai.generate.apply') }}
</Button>
<Button v-if="canRetry" variant="outline" @click="retry">
{{ $t('posts.ai.generate.retry') }}
</Button>
<Button variant="outline" @click="open = false">
{{ $t('posts.ai.generate.cancel') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>