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:
parent
ed4595d960
commit
dbc4aa3c68
4 changed files with 75 additions and 8 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue