fix(ai): wire up streaming generate dialog (event names + JSON extract + loading state)

The inline "Generate post with AI" dialog never showed any output, even
after the queue job finished. Three independent issues stacked:

1. Event name mismatch in useAiStream. Listeners were CamelCased
   (.TextDelta, .StreamEnd, .Error) but laravel/ai broadcasts events
   with snake_case names via StreamEvent::type() (text_delta, stream_end,
   error). Echo never matched, the listener never fired, status stuck on
   'streaming' forever. Renamed to snake_case.

2. The streamer agent shares the structured-output prompt template with
   PostContentGenerator, so the stream emits a JSON object like
   {"content":"...","image_title":"...","image_keywords":[...]}, not
   plain caption text. Added a previewText computed in AiGenerateDialog
   that JSON.parse's the accumulated text and grabs .content. Mid-stream
   the parse fails, so the preview stays empty (showing the existing
   '...' placeholder) until stream_end produces a complete JSON.

3. The Generate button used v-if="status === 'idle'" — it disappeared
   the instant generation started, leaving the user with no feedback.
   Swapped to v-if="status === 'idle' || status === 'streaming'" and
   used the Button component's built-in :loading prop so it stays
   visible with a spinner inside while the agent runs.
This commit is contained in:
Paulo Castellano 2026-05-11 21:42:50 -03:00
parent 740c921e12
commit efdf902b80
2 changed files with 25 additions and 8 deletions

View file

@ -45,8 +45,24 @@ const startGeneration = async () => {
}
};
// 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', text.value);
emit('apply', previewText.value);
open.value = false;
};
@ -56,7 +72,7 @@ const retry = () => {
startGeneration();
};
const canApply = computed(() => status.value === 'completed' && text.value.trim().length > 0);
const canApply = computed(() => status.value === 'completed' && previewText.value.trim().length > 0);
const canRetry = computed(() => status.value === 'completed' || status.value === 'failed');
watch(open, () => {
@ -88,15 +104,16 @@ watch(open, () => {
<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 rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ text || '...' }}</div>
<div class="min-h-[120px] whitespace-pre-wrap 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'"
:disabled="! prompt.trim() || dispatching"
v-if="status === 'idle' || status === 'streaming'"
:loading="dispatching || status === 'streaming'"
:disabled="! prompt.trim()"
@click="startGeneration"
>
{{ $t('posts.ai.generate.start') }}

View file

@ -42,13 +42,13 @@ export const useAiStream = () => {
subscribedName = channelName;
echo().private(channelName)
.listen('.TextDelta', (e: TextDeltaEvent) => {
.listen('.text_delta', (e: TextDeltaEvent) => {
text.value += e.delta ?? '';
})
.listen('.StreamEnd', () => {
.listen('.stream_end', () => {
status.value = 'completed';
})
.listen('.Error', (e: ErrorEvent) => {
.listen('.error', (e: ErrorEvent) => {
status.value = 'failed';
errorMessage.value = e?.message ?? 'AI generation failed';
});