diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue index 95d0ea5..919e13b 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -341,6 +341,15 @@ Suggest Recipes + + {{ isStreaming ? 'Streaming…' : 'Stream (L' + recipesStore.level + ')' }} + + + + + + Generating recipe… + + {{ streamError }} + {{ streamChunks }} + + Recipe request queued, waiting for model… @@ -728,9 +747,14 @@ import CommunityFeedPanel from './CommunityFeedPanel.vue' import BuildYourOwnTab from './BuildYourOwnTab.vue' import OrchUsagePill from './OrchUsagePill.vue' 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' +// Streaming state +const isStreaming = ref(false) +const streamChunks = ref('') +const streamError = ref(null) + const recipesStore = useRecipesStore() const inventoryStore = useInventoryStore() const settingsStore = useSettingsStore() @@ -1125,6 +1149,49 @@ function onNutritionInput(key: NutritionKey, e: Event) { 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 async function handleSuggest() { isLoadingMore.value = false @@ -1738,4 +1805,48 @@ details[open] .collapsible-summary::before { min-height: 24px; 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; +}
{{ streamChunks }}