feat(streaming): add EventSource streaming UI to RecipesView
This commit is contained in:
parent
7292c5e7fc
commit
1182c6cffb
1 changed files with 112 additions and 1 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue