feat(frontend): async polling for L3/L4 recipe generation + rename cf-orch node to sif

Frontend now uses the async job queue for level 3/4 requests instead
of a 120s blocking POST. Submits with ?async=true, gets job_id, then
polls every 2.5s up to 90s. Button label reflects live server state:
'Queued...' while waiting, 'Generating...' while the model runs.

- api.ts: RecipeJobStatus interface + suggestAsync/pollJob methods
- store: jobStatus ref (null|queued|running|done|failed); suggest()
  branches on level >= 3 to _suggestAsync(); CLOUD_MODE sync fallback
  detected via 'suggestions' key on the response
- RecipesView: button spinner text uses jobStatus; aria-live
  announcements updated for each phase (queued/running/finding)
- compose.override.yml: cf-orch agent --node-id renamed kiwi -> sif
  for the upcoming Sif hardware node
This commit is contained in:
pyr0ball 2026-04-19 21:52:21 -07:00
parent ed4595d960
commit dbc4aa3c68
4 changed files with 75 additions and 8 deletions

View file

@ -8,9 +8,9 @@ services:
# Docker can follow the symlink inside the container.
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
# cf-orch agent sidecar: registers kiwi as a GPU node with the coordinator.
# cf-orch agent sidecar: registers this machine as GPU node "sif" with the coordinator.
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
# agent makes kiwi's VRAM usage visible on the orchestrator dashboard.
# agent makes the local VRAM usage visible on the orchestrator dashboard.
cf-orch-agent:
image: kiwi-api # reuse local api image — cf-core already installed there
network_mode: host
@ -21,7 +21,7 @@ services:
command: >
conda run -n kiwi cf-orch agent
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
--node-id kiwi
--node-id sif
--host 0.0.0.0
--port 7702
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}

View file

@ -287,7 +287,10 @@
@click="handleSuggest"
>
<span v-if="recipesStore.loading && !isLoadingMore">
<span class="spinner spinner-sm inline-spinner"></span> Finding recipes
<span class="spinner spinner-sm inline-spinner"></span>
<span v-if="recipesStore.jobStatus === 'queued'">Queued</span>
<span v-else-if="recipesStore.jobStatus === 'running'">Generating</span>
<span v-else>Finding recipes</span>
</span>
<span v-else>Suggest Recipes</span>
</button>
@ -312,7 +315,9 @@
<!-- Screen reader announcement for loading + results -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<span v-if="recipesStore.loading">Finding recipes</span>
<span v-if="recipesStore.loading && recipesStore.jobStatus === 'queued'">Recipe request queued, waiting for model</span>
<span v-else-if="recipesStore.loading && recipesStore.jobStatus === 'running'">Generating your recipe now</span>
<span v-else-if="recipesStore.loading">Finding recipes</span>
<span v-else-if="recipesStore.result">
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
</span>

View file

@ -524,6 +524,15 @@ export interface RecipeResult {
rate_limit_count: number
}
export type RecipeJobStatusValue = 'queued' | 'running' | 'done' | 'failed'
export interface RecipeJobStatus {
job_id: string
status: RecipeJobStatusValue
result: RecipeResult | null
error: string | null
}
export interface RecipeRequest {
pantry_items: string[]
secondary_pantry_items: Record<string, string>
@ -593,6 +602,18 @@ export const recipesAPI = {
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
return response.data
},
/** Submit an async job for L3/L4 generation. Returns job_id + initial status. */
async suggestAsync(req: RecipeRequest): Promise<{ job_id: string; status: string }> {
const response = await api.post('/recipes/suggest', req, { params: { async: 'true' }, timeout: 15000 })
return response.data
},
/** Poll an async job. Returns the full status including result once done. */
async pollJob(jobId: string): Promise<RecipeJobStatus> {
const response = await api.get(`/recipes/jobs/${jobId}`, { timeout: 10000 })
return response.data
},
async getRecipe(id: number): Promise<RecipeSuggestion> {
const response = await api.get(`/recipes/${id}`)
return response.data

View file

@ -7,7 +7,7 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type NutritionFilters } from '../services/api'
import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeRequest, type RecipeJobStatusValue, type NutritionFilters } from '../services/api'
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
@ -121,6 +121,7 @@ export const useRecipesStore = defineStore('recipes', () => {
const result = ref<RecipeResult | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const jobStatus = ref<RecipeJobStatusValue | null>(null)
// Request parameters
const level = ref(1)
@ -199,18 +200,57 @@ export const useRecipesStore = defineStore('recipes', () => {
async function suggest(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
loading.value = true
error.value = null
jobStatus.value = null
seenIds.value = new Set()
try {
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
_trackSeen(result.value.suggestions)
if (level.value >= 3) {
await _suggestAsync(pantryItems, secondaryPantryItems)
} else {
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
_trackSeen(result.value.suggestions)
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
} finally {
loading.value = false
jobStatus.value = null
}
}
async function _suggestAsync(pantryItems: string[], secondaryPantryItems: Record<string, string>) {
const queued = await recipesAPI.suggestAsync(_buildRequest(pantryItems, secondaryPantryItems))
// CLOUD_MODE or future sync fallback: server returned result directly (status 200)
if ('suggestions' in queued) {
result.value = queued as unknown as RecipeResult
_trackSeen(result.value.suggestions)
return
}
jobStatus.value = 'queued'
const { job_id } = queued
const deadline = Date.now() + 90_000
const POLL_MS = 2_500
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, POLL_MS))
const poll = await recipesAPI.pollJob(job_id)
jobStatus.value = poll.status
if (poll.status === 'done') {
result.value = poll.result
if (result.value) _trackSeen(result.value.suggestions)
return
}
if (poll.status === 'failed') {
throw new Error(poll.error ?? 'Recipe generation failed')
}
}
throw new Error('Recipe generation timed out — the model may be busy. Try again.')
}
async function loadMore(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
if (!result.value || loading.value) return
loading.value = true
@ -308,6 +348,7 @@ export const useRecipesStore = defineStore('recipes', () => {
result,
loading,
error,
jobStatus,
level,
constraints,
allergies,