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.
|
# Docker can follow the symlink inside the container.
|
||||||
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
|
- /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
|
# 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:
|
cf-orch-agent:
|
||||||
image: kiwi-api # reuse local api image — cf-core already installed there
|
image: kiwi-api # reuse local api image — cf-core already installed there
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|
@ -21,7 +21,7 @@ services:
|
||||||
command: >
|
command: >
|
||||||
conda run -n kiwi cf-orch agent
|
conda run -n kiwi cf-orch agent
|
||||||
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
||||||
--node-id kiwi
|
--node-id sif
|
||||||
--host 0.0.0.0
|
--host 0.0.0.0
|
||||||
--port 7702
|
--port 7702
|
||||||
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}
|
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,10 @@
|
||||||
@click="handleSuggest"
|
@click="handleSuggest"
|
||||||
>
|
>
|
||||||
<span v-if="recipesStore.loading && !isLoadingMore">
|
<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>
|
||||||
<span v-else>Suggest Recipes</span>
|
<span v-else>Suggest Recipes</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -312,7 +315,9 @@
|
||||||
|
|
||||||
<!-- 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">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">
|
<span v-else-if="recipesStore.result">
|
||||||
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
|
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -524,6 +524,15 @@ export interface RecipeResult {
|
||||||
rate_limit_count: number
|
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 {
|
export interface RecipeRequest {
|
||||||
pantry_items: string[]
|
pantry_items: string[]
|
||||||
secondary_pantry_items: Record<string, string>
|
secondary_pantry_items: Record<string, string>
|
||||||
|
|
@ -593,6 +602,18 @@ export const recipesAPI = {
|
||||||
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
|
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
|
||||||
return response.data
|
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> {
|
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
||||||
const response = await api.get(`/recipes/${id}`)
|
const response = await api.get(`/recipes/${id}`)
|
||||||
return response.data
|
return response.data
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed, watch } from 'vue'
|
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 DISMISSED_KEY = 'kiwi:dismissed_recipes'
|
||||||
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
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 result = ref<RecipeResult | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const jobStatus = ref<RecipeJobStatusValue | null>(null)
|
||||||
|
|
||||||
// Request parameters
|
// Request parameters
|
||||||
const level = ref(1)
|
const level = ref(1)
|
||||||
|
|
@ -199,18 +200,57 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
async function suggest(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
|
async function suggest(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
jobStatus.value = null
|
||||||
seenIds.value = new Set()
|
seenIds.value = new Set()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
|
if (level.value >= 3) {
|
||||||
_trackSeen(result.value.suggestions)
|
await _suggestAsync(pantryItems, secondaryPantryItems)
|
||||||
|
} else {
|
||||||
|
result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
|
||||||
|
_trackSeen(result.value.suggestions)
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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> = {}) {
|
async function loadMore(pantryItems: string[], secondaryPantryItems: Record<string, string> = {}) {
|
||||||
if (!result.value || loading.value) return
|
if (!result.value || loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
@ -308,6 +348,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
result,
|
result,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
jobStatus,
|
||||||
level,
|
level,
|
||||||
constraints,
|
constraints,
|
||||||
allergies,
|
allergies,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue