feat(streaming): add EventSource streaming UI to RecipesView

This commit is contained in:
pyr0ball 2026-04-24 10:25:35 -07:00
parent 7292c5e7fc
commit 1182c6cffb

View file

@ -341,6 +341,15 @@
</span> </span>
<span v-else>Suggest Recipes</span> <span v-else>Suggest Recipes</span>
</button> </button>
<button
v-if="recipesStore.level === 3 || recipesStore.level === 4"
class="btn btn-secondary btn-sm"
:disabled="isStreaming || recipesStore.loading || pantryItems.length === 0"
@click="streamRecipe(recipesStore.level as 3 | 4, recipesStore.wildcardConfirmed)"
title="Stream recipe generation token-by-token via cf-orch"
>
{{ isStreaming ? 'Streaming…' : 'Stream (L' + recipesStore.level + ')' }}
</button>
<button <button
v-if="recipesStore.dismissedCount > 0" v-if="recipesStore.dismissedCount > 0"
class="btn btn-ghost btn-sm" class="btn btn-ghost btn-sm"
@ -360,6 +369,16 @@
{{ recipesStore.error }} {{ recipesStore.error }}
</div> </div>
<!-- Streaming recipe generation panel -->
<div v-if="isStreaming || streamChunks || streamError" class="stream-panel">
<div v-if="isStreaming" class="stream-status">
<span class="stream-dot" aria-hidden="true"></span>
Generating recipe
</div>
<div v-if="streamError" class="stream-error text-sm">{{ streamError }}</div>
<pre v-if="streamChunks" class="stream-output">{{ streamChunks }}</pre>
</div>
<!-- Screen reader announcement for loading + results --> <!-- Screen reader announcement for loading + results -->
<div aria-live="polite" aria-atomic="true" class="sr-only"> <div aria-live="polite" aria-atomic="true" class="sr-only">
<span v-if="recipesStore.loading && recipesStore.jobStatus === 'queued'">Recipe request queued, waiting for model</span> <span v-if="recipesStore.loading && recipesStore.jobStatus === 'queued'">Recipe request queued, waiting for model</span>
@ -728,9 +747,14 @@ import CommunityFeedPanel from './CommunityFeedPanel.vue'
import BuildYourOwnTab from './BuildYourOwnTab.vue' import BuildYourOwnTab from './BuildYourOwnTab.vue'
import OrchUsagePill from './OrchUsagePill.vue' import OrchUsagePill from './OrchUsagePill.vue'
import type { ForkResult } from '../stores/community' import type { ForkResult } from '../stores/community'
import type { RecipeSuggestion, GroceryLink } from '../services/api' import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
import { recipesAPI } from '../services/api' import { recipesAPI } from '../services/api'
// Streaming state
const isStreaming = ref(false)
const streamChunks = ref('')
const streamError = ref<string | null>(null)
const recipesStore = useRecipesStore() const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore() const inventoryStore = useInventoryStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
@ -1125,6 +1149,49 @@ function onNutritionInput(key: NutritionKey, e: Event) {
recipesStore.nutritionFilters[key] = isNaN(val) ? null : val recipesStore.nutritionFilters[key] = isNaN(val) ? null : val
} }
// Streaming recipe generation
async function streamRecipe(level: 3 | 4, wildcardConfirmed = false) {
isStreaming.value = true
streamChunks.value = ''
streamError.value = null
let tokenData: StreamTokenResponse
try {
tokenData = await recipesAPI.getRecipeStreamToken({ level, wildcard_confirmed: wildcardConfirmed })
} catch (err: unknown) {
isStreaming.value = false
streamError.value = err instanceof Error ? err.message : 'Failed to start stream'
return
}
const url = `${tokenData.stream_url}?token=${encodeURIComponent(tokenData.token)}`
const es = new EventSource(url)
es.onmessage = (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
if (data.done) {
es.close()
isStreaming.value = false
} else if (data.error) {
es.close()
isStreaming.value = false
streamError.value = data.error
} else if (data.chunk) {
streamChunks.value += data.chunk
}
} catch {
// ignore malformed events
}
}
es.onerror = () => {
es.close()
isStreaming.value = false
streamError.value = 'Stream connection lost'
}
}
// Suggest handler // Suggest handler
async function handleSuggest() { async function handleSuggest() {
isLoadingMore.value = false isLoadingMore.value = false
@ -1738,4 +1805,48 @@ details[open] .collapsible-summary::before {
min-height: 24px; min-height: 24px;
padding: 2px 4px; padding: 2px 4px;
} }
.stream-panel {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.stream-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.stream-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-warning);
animation: stream-pulse 1.2s ease-in-out infinite;
}
@keyframes stream-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.stream-error {
color: var(--color-danger, #e05c5c);
margin-bottom: 0.5rem;
}
.stream-output {
font-family: inherit;
white-space: pre-wrap;
font-size: var(--font-size-sm);
color: var(--color-text);
margin: 0;
max-height: 400px;
overflow-y: auto;
}
</style> </style>