Compare commits

...

4 commits

Author SHA1 Message Date
59b183a898 feat(ask): Add Ask tab — natural-language recipe search with session history
Some checks are pending
CI / Frontend (Vue) (push) Waiting to run
CI / Backend (Python) (push) Waiting to run
Mirror / mirror (push) Waiting to run
- New Ask tab in recipe browser tab bar (alongside Find/Browse/Saved)
- Text input + Search button; Enter to submit
- 4 example question chips shown in empty state
- Results as clickable recipe cards (opens RecipeDetailPanel)
- Pantry match_pct badge on each card when pantry items are available
- LLM-synthesized answer shown above results (paid tier)
- Session history: last 3 questions shown as re-runnable chips
- Keyboard navigable (tab key, Enter on card, Arrow keys on tab bar)
- ARIA: role=tabpanel, aria-labelledby, aria-live for error/answer regions

Also fixes pre-existing build issues now caught by vue-tsc:
- Move pantryItems/secondaryPantryItems declarations before auto-suggest
  watcher that uses them (TS2448 block-scoped variable before declaration)
- Fix nullable regex capture group access in parsedStream computed (TS2532)
  using optional chaining (titleMatch?.[1], ingMatch?.[1], etc.)

Closes #134
2026-05-11 13:08:06 -07:00
b4624fba84 feat(ask): add POST /recipes/ask endpoint for natural-language recipe search
Free tier: keyword extraction + FTS ingredient search + title probe search.
Paid tier / BYOK: same search, then LLM synthesis of a conversational answer
(8s timeout so an unresponsive model degrades gracefully to recipe list only).

- AskRequest / AskRecipeHit / AskResponse schemas in recipe.py
- _extract_ask_keywords(): tokenize question, strip stopwords
- _ask_in_thread(): two-pronged search (ingredient FTS + title LIKE)
  merges by ID, computes pantry match_pct when pantry_items provided
- Endpoint registered before /{recipe_id} to avoid integer coercion on /ask
- LLM synthesis gated to paid/premium/ultra only (not "local" dev tier)

Closes #134 (backend)
2026-05-11 13:07:53 -07:00
667daf939e feat(streaming): replace raw <pre> with skeleton + progressive reveal (closes #133)
Parses the streamed LLM output (Title / Ingredients / Directions / Notes
plain-text format) on the fly as tokens arrive. Shows a shimmer skeleton
for each section while that section has not yet arrived, then swaps in
real content as the parse succeeds — title first, then ingredients, then
numbered steps, then notes on completion.

parsedStream computed: matches Title, Ingredients (comma-split), numbered
step lines, and Notes sections from the accumulating streamChunks string.

Skeleton shimmer is CSS-only (no JS); respects prefers-reduced-motion by
falling back to a static placeholder color. The stream-output <pre> block
is removed from the template entirely — raw tokens never reach the user.
2026-05-11 12:46:27 -07:00
4e50661483 feat(find): invert flow — auto-suggest on tab open, collapsible Refine panel (closes #132)
Auto-suggest (L1/L2 only):
  When the Find tab is activated with a non-empty pantry and no existing
  results, suggestion fires immediately without user action. L3/L4 are
  excluded to avoid unintended VRAM allocation and AI quota charges.
  After the first auto-suggest completes, the Refine panel collapses so
  the results are the first thing the user sees.

Live re-suggest (L1/L2 only):
  A single filterKey computed wraps all filter state as JSON. Any filter
  change while on the Find tab with existing results triggers a debounced
  (1.2s) re-suggest, keeping the result list live without button clicks.

Refine collapsible:
  Time budget, Dietary preferences, and Nutrition/Advanced filters are
  wrapped in a v-show panel controlled by filtersOpen (persisted to
  localStorage under kiwi:find_filters_open, default open). Level
  selector, Hard Day Mode, and the Suggest button remain always visible.
  Toggle button shows active filter count badge when any filter is set.
2026-05-11 12:41:58 -07:00
4 changed files with 675 additions and 42 deletions

View file

@ -16,6 +16,9 @@ log = logging.getLogger(__name__)
from app.db.session import get_store
from app.db.store import Store
from app.models.schemas.recipe import (
AskRequest,
AskResponse,
AskRecipeHit,
AssemblyTemplateOut,
BuildRequest,
LeftoversResponse,
@ -597,6 +600,137 @@ async def build_recipe(
return result
_ASK_STOPWORDS: frozenset[str] = frozenset({
"what", "can", "make", "with", "have", "some", "the", "and", "for",
"that", "this", "these", "those", "how", "about", "are", "there",
"give", "show", "find", "want", "need", "like", "any", "good",
"quick", "easy", "simple", "fast", "using", "use", "from", "into",
"more", "much", "just", "only", "my", "please", "could", "would",
"should", "something", "anything", "everything", "ideas", "idea",
"suggest", "meal", "food", "dish", "dishes", "today", "tonight",
"tomorrow", "now", "here", "there", "recipes", "recipe", "dinner",
"lunch", "breakfast", "snack", "under", "minutes", "hours", "time",
"left", "over", "also", "some", "make", "cook", "made", "cooked",
})
import re as _re
def _extract_ask_keywords(question: str) -> list[str]:
"""Extract food-relevant keywords from a natural language question."""
tokens = _re.findall(r"[a-zA-Z]+", question.lower())
return [t for t in tokens if len(t) > 3 and t not in _ASK_STOPWORDS]
def _ask_in_thread(db_path: Path, question: str, pantry_items: list[str]) -> AskResponse:
"""Run Ask logic in a worker thread.
Free tier: keyword extraction + FTS ingredient search.
Paid tier path: same search, then LLM synthesis over results.
The caller handles tier gating and LLM synthesis outside this thread
to avoid importing LLMRouter in a sync context.
"""
import json as _json
store = Store(db_path)
try:
keywords = _extract_ask_keywords(question)
ingredient_hits: list[dict] = []
if keywords:
ingredient_hits = store.search_recipes_by_ingredients(keywords, limit=15)
# Also search by title using the full question text as a substring hint.
# browse_recipes q= does title LIKE %q%. Extract the longest keyword
# from the question as the title probe (most likely to appear in a title).
title_hits: list[dict] = []
title_probe = max(keywords, key=len) if keywords else None
if title_probe:
browse_result = store.browse_recipes(
keywords=None,
page=1,
page_size=12,
pantry_items=pantry_items or None,
q=title_probe,
sort="match" if pantry_items else "default",
)
title_hits = browse_result.get("recipes", [])
# Merge by ID; ingredient hits come first (more semantically relevant).
seen: set[int] = set()
merged: list[dict] = []
for row in ingredient_hits + title_hits:
rid = row.get("id")
if rid is not None and rid not in seen:
seen.add(rid)
merged.append(row)
# Compute pantry match_pct if caller sent pantry items.
pantry_set = {p.lower() for p in pantry_items} if pantry_items else set()
hits: list[AskRecipeHit] = []
for row in merged[:12]:
match_pct: float | None = None
if pantry_set:
raw_names = row.get("ingredient_names") or []
if isinstance(raw_names, str):
try:
raw_names = _json.loads(raw_names)
except Exception:
raw_names = []
if raw_names:
covered = sum(
1 for n in raw_names
if any(p in n.lower() for p in pantry_set)
)
match_pct = round(covered / len(raw_names), 2)
hits.append(AskRecipeHit(
id=row["id"],
title=row.get("title", ""),
category=row.get("category"),
match_pct=match_pct,
))
return AskResponse(answer=None, recipes=hits, tier="free")
finally:
store.close()
@router.post("/ask", response_model=AskResponse)
async def ask_recipes(
req: AskRequest,
session: CloudUser = Depends(get_session),
) -> AskResponse:
"""Natural-language recipe search with optional LLM synthesis.
Free tier: keyword extraction from question FTS ingredient + title search.
Paid tier / BYOK: same search, then LLM synthesizes a short conversational answer.
"""
result = await asyncio.to_thread(_ask_in_thread, session.db, req.question, req.pantry_items)
# LLM synthesis: only for paid/premium/ultra tiers, not "local" dev tier.
# Wrapped in wait_for so an unresponsive model degrades gracefully to recipe list only.
paid_tier = session.tier in ("paid", "premium", "ultra")
if (paid_tier or session.has_byok) and result.recipes:
recipe_titles = ", ".join(r.title for r in result.recipes[:6])
prompt = (
f'You are a helpful kitchen assistant. The user asked: "{req.question}"\n\n'
f"Matching recipes: {recipe_titles}\n\n"
f"Write a brief, friendly 12 sentence response suggesting which of these "
f"recipes might best fit the question. Be specific and natural."
)
try:
from circuitforge_core.llm.router import LLMRouter
answer = await asyncio.wait_for(
asyncio.to_thread(LLMRouter().complete, prompt),
timeout=8.0,
)
result = result.model_copy(update={"answer": answer.strip() or None, "tier": "paid"})
except (Exception, asyncio.TimeoutError) as exc:
log.warning("Ask LLM synthesis skipped: %s", exc)
return result
@router.get("/{recipe_id}")
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
def _get(db_path: Path, rid: int) -> dict | None:

View file

@ -206,3 +206,24 @@ class StreamTokenResponse(BaseModel):
stream_url: str
token: str
expires_in_s: int
class AskRequest(BaseModel):
"""Request body for POST /recipes/ask."""
question: str = Field(min_length=1, max_length=500)
pantry_items: list[str] = Field(default_factory=list)
class AskRecipeHit(BaseModel):
"""A single recipe result from the Ask endpoint."""
id: int
title: str
match_pct: float | None = None
category: str | None = None
class AskResponse(BaseModel):
"""Response from POST /recipes/ask."""
answer: str | None = None # LLM-synthesized response (Paid tier only)
recipes: list[AskRecipeHit]
tier: str

View file

@ -89,6 +89,118 @@
tabindex="0"
/>
<!-- Ask tab natural-language recipe search with optional LLM synthesis -->
<div
v-else-if="activeTab === 'ask'"
ref="askPanelRef"
role="tabpanel"
aria-labelledby="tab-ask"
tabindex="0"
class="ask-panel"
>
<div class="card mb-md">
<h2 class="section-title text-xl mb-xs">Ask about recipes</h2>
<p class="text-sm text-secondary mb-md">Search by ingredient, dish type, or any cooking question.</p>
<!-- Question input -->
<div class="ask-input-row">
<input
type="text"
v-model="askQuestion"
class="form-input ask-input"
placeholder="e.g. What can I make with chicken and pasta?"
aria-label="Recipe question"
@keydown="onAskKeydown"
:disabled="askLoading"
/>
<button
class="btn btn-primary ask-submit"
:disabled="askLoading || !askQuestion.trim()"
@click="handleAsk()"
aria-label="Search recipes"
>
<span v-if="askLoading" class="spinner spinner-sm inline-spinner"></span>
<span v-else>Search</span>
</button>
</div>
<!-- Example questions shown before first search -->
<div v-if="!askResult && !askLoading" class="ask-examples">
<p class="text-xs text-secondary mb-xs">Try asking:</p>
<div class="flex flex-wrap gap-xs">
<button
v-for="ex in ASK_EXAMPLES"
:key="ex"
class="btn btn-xs btn-secondary ask-example-btn"
@click="handleAsk(ex)"
>{{ ex }}</button>
</div>
</div>
<!-- Error state -->
<div v-if="askError" role="alert" class="status-badge status-error mt-sm">
{{ askError }}
</div>
</div>
<!-- Result: LLM answer + recipe list -->
<div v-if="askResult">
<!-- LLM-synthesized answer (Paid tier) -->
<div v-if="askResult.answer" class="ask-answer card mb-md" role="status" aria-live="polite">
<p class="text-sm">{{ askResult.answer }}</p>
</div>
<!-- Recipe results -->
<div v-if="askResult.recipes.length > 0" class="mb-md">
<p class="text-sm text-secondary mb-sm">
{{ askResult.recipes.length }} recipe{{ askResult.recipes.length !== 1 ? 's' : '' }} found
</p>
<div class="grid-auto">
<div
v-for="hit in askResult.recipes"
:key="hit.id"
class="card recipe-card-compact ask-hit-card slide-up"
role="article"
tabindex="0"
@click="openRecipeById(hit.id)"
@keydown.enter="openRecipeById(hit.id)"
:aria-label="'View recipe: ' + hit.title"
>
<h3 class="text-base font-bold mb-xs recipe-title">{{ hit.title }}</h3>
<div class="flex flex-wrap gap-xs">
<span v-if="hit.category" class="status-badge status-neutral text-xs">{{ hit.category }}</span>
<span
v-if="hit.match_pct !== null"
:class="['status-badge', 'text-xs', hit.match_pct >= 0.7 ? 'status-success' : hit.match_pct >= 0.4 ? 'status-info' : 'status-neutral']"
>{{ Math.round(hit.match_pct * 100) }}% pantry match</span>
</div>
</div>
</div>
</div>
<!-- No results -->
<div v-else class="card text-center text-muted">
<p>No recipes found for this question. Try different keywords.</p>
</div>
</div>
<!-- Session history (last 3 questions) -->
<div v-if="askHistory.length > 1" class="ask-history mt-md">
<div class="flex-between mb-xs">
<p class="text-xs text-secondary">Recent questions</p>
<button class="btn btn-xs btn-ghost" @click="askHistory = []" aria-label="Clear question history">Clear</button>
</div>
<div class="flex flex-wrap gap-xs">
<button
v-for="entry in askHistory.slice(1)"
:key="entry.question"
class="btn btn-xs btn-secondary ask-history-btn"
@click="handleAsk(entry.question)"
>{{ entry.question }}</button>
</div>
</div>
</div>
<!-- Find tab (existing search UI) -->
<div v-else ref="findPanelRef" role="tabpanel" aria-labelledby="tab-find" tabindex="0">
<!-- Controls Panel -->
@ -147,6 +259,19 @@
Tap "Find recipes" again to apply.
</p>
<!-- Refine panel toggle wraps time budget + dietary + nutrition filters -->
<button
class="refine-toggle btn btn-secondary btn-sm"
@click="toggleFilters"
:aria-expanded="filtersOpen"
aria-controls="refine-panel"
>
{{ filtersOpen ? '▲' : '▼' }} Refine
<span v-if="activeFilterCount > 0" class="refine-count">{{ activeFilterCount }}</span>
</button>
<div id="refine-panel" v-show="filtersOpen" class="refine-body">
<!-- Time Budget selector always visible; closes #131 -->
<div class="form-group time-bucket-group">
<!-- Hands-on / active time row -->
@ -400,6 +525,8 @@
</div>
</details>
</div><!-- end #refine-panel -->
<!-- Suggest Button -->
<div class="suggest-row">
<button
@ -445,12 +572,50 @@
<!-- Streaming recipe generation panel -->
<div v-if="isStreaming || streamChunks || streamError" class="stream-panel">
<div v-if="isStreaming" class="stream-status">
<div v-if="isStreaming" class="stream-status" role="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 v-if="streamError" class="stream-error text-sm" role="alert">{{ streamError }}</div>
<!-- Progressive reveal card: skeleton while streaming, full card on complete -->
<div v-if="isStreaming || (streamChunks && !streamError)" class="stream-recipe-card card">
<!-- Title -->
<div class="stream-title mb-sm">
<h3 v-if="parsedStream.title" class="text-lg font-semibold">{{ parsedStream.title }}</h3>
<div v-else class="skeleton-line skeleton-title"></div>
</div>
<!-- Ingredients -->
<div class="stream-section mb-sm">
<strong class="stream-section-label text-sm">Ingredients</strong>
<ul v-if="parsedStream.ingredients.length > 0" class="stream-ingredients mt-xs">
<li v-for="(ing, i) in parsedStream.ingredients" :key="i" class="text-sm">{{ ing }}</li>
</ul>
<template v-else-if="isStreaming">
<div class="skeleton-line skeleton-medium mt-xs"></div>
<div class="skeleton-line skeleton-short"></div>
<div class="skeleton-line skeleton-long"></div>
</template>
</div>
<!-- Steps only show once ingredients have arrived -->
<div v-if="parsedStream.steps.length > 0 || (isStreaming && parsedStream.ingredients.length > 0)" class="stream-section mb-sm">
<strong class="stream-section-label text-sm">Directions</strong>
<ol v-if="parsedStream.steps.length > 0" class="stream-steps mt-xs">
<li v-for="(step, i) in parsedStream.steps" :key="i" class="text-sm">{{ step }}</li>
</ol>
<template v-else-if="isStreaming">
<div class="skeleton-line skeleton-long mt-xs"></div>
<div class="skeleton-line skeleton-medium"></div>
</template>
</div>
<!-- Notes only after stream complete -->
<p v-if="parsedStream.notes && !isStreaming" class="stream-notes text-sm text-muted">
<strong>Notes:</strong> {{ parsedStream.notes }}
</p>
</div>
</div>
<!-- Screen reader announcement for loading + results -->
@ -726,7 +891,7 @@ import BuildYourOwnTab from './BuildYourOwnTab.vue'
import OrchUsagePill from './OrchUsagePill.vue'
import RecipeScanModal from './RecipeScanModal.vue'
import type { ForkResult } from '../stores/community'
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse, AskResponse } from '../services/api'
import { recipesAPI } from '../services/api'
// Scan modal
@ -745,25 +910,62 @@ const isStreaming = ref(false)
const streamChunks = ref('')
const streamError = ref<string | null>(null)
interface ParsedStreamRecipe {
title: string | null
ingredients: string[]
steps: string[]
notes: string | null
}
const parsedStream = computed((): ParsedStreamRecipe => {
const text = streamChunks.value
if (!text) return { title: null, ingredients: [], steps: [], notes: null }
const titleMatch = text.match(/^Title:\s*(.+)$/m)
const title = titleMatch?.[1]?.trim() ?? null
const ingMatch = text.match(/^Ingredients:\s*(.+)$/m)
const ingredients = ingMatch?.[1]
? ingMatch[1]!.split(',').map((s) => s.trim()).filter(Boolean)
: []
const steps: string[] = []
const dirIdx = text.indexOf('Directions:')
const notesIdx = text.indexOf('\nNotes:')
if (dirIdx !== -1) {
const dirSection = text.slice(dirIdx + 'Directions:'.length, notesIdx !== -1 ? notesIdx : undefined)
for (const m of dirSection.matchAll(/^\d+\.\s*(.+)$/mg)) {
steps.push(m[1]?.trim() ?? '')
}
}
const notesMatch = text.match(/^Notes:\s*([\s\S]+)$/m)
const notes = notesMatch?.[1]?.trim() ?? null
return { title, ingredients, steps, notes }
})
const recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore()
// Tab state
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build' | 'ask'
const tabs: Array<{ id: TabId; label: string; mobileLabel?: string }> = [
{ id: 'saved', label: 'Saved' },
{ id: 'build', label: 'Build Your Own', mobileLabel: 'Build' },
{ id: 'community', label: 'Community' },
{ id: 'find', label: 'Find' },
{ id: 'browse', label: 'Browse' },
{ id: 'ask', label: 'Ask' },
]
const activeTab = ref<TabId>('saved')
const savedStore = useSavedRecipesStore()
// Template ref for the Find-tab panel div (used for focus management on tab switch)
// Template refs for panels that need explicit focus management on tab switch
const findPanelRef = ref<HTMLElement | null>(null)
const askPanelRef = ref<HTMLElement | null>(null)
function onTabKeydown(e: KeyboardEvent) {
const tabIds: TabId[] = ['saved', 'build', 'community', 'find', 'browse']
const tabIds: TabId[] = ['saved', 'build', 'community', 'find', 'browse', 'ask']
const current = tabIds.indexOf(activeTab.value)
if (e.key === 'ArrowRight') {
e.preventDefault()
@ -783,6 +985,8 @@ async function activateTab(tab: TabId) {
// components so we locate their panel via querySelector.
if (tab === 'find' && findPanelRef.value) {
findPanelRef.value.focus()
} else if (tab === 'ask' && askPanelRef.value) {
askPanelRef.value.focus()
} else {
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
panel?.focus()
@ -985,6 +1189,14 @@ const advancedActive = computed(() =>
!!recipesStore.styleId
)
const FILTERS_OPEN_KEY = 'kiwi:find_filters_open'
const filtersOpen = ref(localStorage.getItem(FILTERS_OPEN_KEY) !== 'false')
function toggleFilters() {
filtersOpen.value = !filtersOpen.value
localStorage.setItem(FILTERS_OPEN_KEY, String(filtersOpen.value))
}
const activeFilterCount = computed(() => {
let n = 0
if (recipesStore.constraints.length > 0) n++
@ -1021,6 +1233,92 @@ function clearAllFindFilters() {
categoryInput.value = ''
}
// Pantry items sorted expiry-first (available items only)
const pantryItems = computed(() => {
const sorted = [...inventoryStore.items]
.filter((item) => item.status === 'available')
.sort((a, b) => {
if (!a.expiration_date && !b.expiration_date) return 0
if (!a.expiration_date) return 1
if (!b.expiration_date) return -1
return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime()
})
return sorted.map((item) => item.product_name).filter(Boolean) as string[]
})
// Secondary-state items: expired but still usable in specific recipes.
// Maps product_name secondary_state label (e.g. "Bread" "stale").
// Sent alongside pantry_items so the recipe engine can boost relevant recipes.
const secondaryPantryItems = computed<Record<string, string>>(() => {
const result: Record<string, string> = {}
for (const item of inventoryStore.items) {
if (item.secondary_state && item.product_name) {
result[item.product_name] = item.secondary_state
}
}
return result
})
// Inverted flow: auto-suggest + live re-suggest (closes #132)
function _debounce(fn: () => void, ms: number): () => void {
let t: ReturnType<typeof setTimeout>
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
}
// Stable key over all filter state one watcher instead of a dozen.
const filterKey = computed(() => JSON.stringify({
constraints: recipesStore.constraints,
allergies: recipesStore.allergies,
excludeIngredients: recipesStore.excludeIngredients,
shoppingMode: recipesStore.shoppingMode,
pantryMatchOnly: recipesStore.pantryMatchOnly,
hardDayMode: recipesStore.hardDayMode,
maxActiveMin: recipesStore.maxActiveMin,
maxTotalMin: recipesStore.maxTotalMin,
maxMissing: recipesStore.maxMissing,
styleId: recipesStore.styleId,
category: recipesStore.category,
nutritionFilters: recipesStore.nutritionFilters,
level: recipesStore.level,
}))
// Flag prevents double-firing on initial pantry load + tab switch within same render cycle.
let _autoSuggestFired = false
// Auto-suggest on Find tab activation (L1/L2 only L3/L4 require explicit user intent).
watch(
[activeTab, pantryItems] as const,
([tab, items]) => {
if (tab !== 'find') return
if (recipesStore.level > 2) return
if (items.length === 0) return
if (recipesStore.result || recipesStore.loading) return
if (_autoSuggestFired) return
_autoSuggestFired = true
handleSuggest().then(() => {
filtersOpen.value = false
localStorage.setItem(FILTERS_OPEN_KEY, 'false')
})
},
)
// Live re-suggest when any filter changes while on Find tab with existing results (L1/L2).
const _debouncedResuggest = _debounce(async () => {
if (activeTab.value !== 'find') return
if (recipesStore.level > 2) return
if (pantryItems.value.length === 0) return
if (!recipesStore.result) return
await handleSuggest()
}, 1200)
watch(filterKey, () => {
if (activeTab.value !== 'find') return
if (recipesStore.level > 2) return
if (!recipesStore.result) return
_debouncedResuggest()
})
// #46 count of active nutrition filters so the summary is informative when collapsed
const activeNutritionFilterCount = computed(() =>
Object.values(recipesStore.nutritionFilters ?? {}).filter((v) => v !== null).length
@ -1054,33 +1352,6 @@ const cuisineStyles = [
{ id: 'eastern_european', label: 'Eastern European' },
]
// Pantry items sorted expiry-first (available items only)
const pantryItems = computed(() => {
const sorted = [...inventoryStore.items]
.filter((item) => item.status === 'available')
.sort((a, b) => {
if (!a.expiration_date && !b.expiration_date) return 0
if (!a.expiration_date) return 1
if (!b.expiration_date) return -1
return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime()
})
return sorted.map((item) => item.product_name).filter(Boolean) as string[]
})
// Secondary-state items: expired but still usable in specific recipes.
// Maps product_name secondary_state label (e.g. "Bread" "stale").
// Sent alongside pantry_items so the recipe engine can boost relevant recipes.
const secondaryPantryItems = computed<Record<string, string>>(() => {
const result: Record<string, string> = {}
for (const item of inventoryStore.items) {
if (item.secondary_state && item.product_name) {
result[item.product_name] = item.secondary_state
}
}
return result
})
// Tag input helpers constraints
function addConstraint(value: string) {
const tag = value.trim().toLowerCase()
@ -1218,6 +1489,51 @@ async function handleLoadMore() {
isLoadingMore.value = false
}
// Ask tab state
interface AskHistoryEntry {
question: string
result: AskResponse
}
const askQuestion = ref('')
const askLoading = ref(false)
const askError = ref<string | null>(null)
const askResult = ref<AskResponse | null>(null)
const askHistory = ref<AskHistoryEntry[]>([])
const ASK_EXAMPLES = [
'What can I make with chicken and pasta?',
'Easy vegetarian dinners under 30 minutes',
'Recipes using overripe bananas',
'Quick breakfasts with eggs',
]
async function handleAsk(question?: string) {
const q = (question ?? askQuestion.value).trim()
if (!q) return
askQuestion.value = q
askLoading.value = true
askError.value = null
askResult.value = null
try {
const result = await recipesAPI.ask(q, pantryItems.value)
askResult.value = result
askHistory.value = [{ question: q, result }, ...askHistory.value].slice(0, 3)
} catch {
askError.value = 'Could not search recipes. Please try again.'
} finally {
askLoading.value = false
}
}
function onAskKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAsk()
}
}
onMounted(async () => {
if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
@ -1452,6 +1768,35 @@ watch(
color: var(--color-text-secondary);
}
/* ── Refine collapsible ──────────────────────────────────────────────────── */
.refine-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-sm);
}
.refine-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.3rem;
border-radius: 9999px;
background: var(--color-primary);
color: white;
font-size: var(--font-size-xs, 0.72rem);
font-weight: 600;
}
.refine-body {
margin-bottom: var(--spacing-xs);
}
.filter-bar {
display: flex;
flex-direction: column;
@ -1929,13 +2274,125 @@ details[open] .collapsible-summary::before {
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;
/* ── Stream recipe card — skeleton + progressive reveal ─────────────────── */
.stream-recipe-card {
margin-top: var(--spacing-sm);
}
.stream-section-label {
display: block;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: var(--font-size-xs, 0.72rem);
margin-bottom: var(--spacing-xs);
}
.stream-ingredients {
padding-left: 1.2rem;
list-style: disc;
display: flex;
flex-direction: column;
gap: 2px;
}
.stream-steps {
padding-left: 1.4rem;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.stream-notes {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-xs);
margin-top: var(--spacing-xs);
}
/* ── Skeleton shimmer ───────────────────────────────────────────────────── */
@keyframes skeleton-shimmer {
0% { background-position: -200px 0; }
100% { background-position: calc(200px + 100%) 0; }
}
.skeleton-line {
height: 0.8rem;
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-secondary, #f5f5f5) 0%,
var(--color-border, #e0e0e0) 50%,
var(--color-bg-secondary, #f5f5f5) 100%
);
background-size: 200px 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
margin-bottom: var(--spacing-xs);
}
.skeleton-title { height: 1.2rem; width: 55%; }
.skeleton-short { width: 38%; }
.skeleton-medium { width: 65%; }
.skeleton-long { width: 88%; }
@media (prefers-reduced-motion: reduce) {
.skeleton-line {
animation: none;
background: var(--color-bg-secondary, #f5f5f5);
}
}
/* ── Ask tab ──────────────────────────────────────────────────────────── */
.ask-panel {
padding-top: var(--spacing-sm);
}
.ask-input-row {
display: flex;
gap: var(--spacing-xs);
align-items: flex-start;
}
.ask-input {
flex: 1;
min-width: 0;
}
.ask-submit {
flex-shrink: 0;
white-space: nowrap;
}
.ask-examples {
margin-top: var(--spacing-md);
}
.ask-example-btn {
white-space: normal;
text-align: left;
max-width: 100%;
}
.ask-answer {
border-left: 3px solid var(--color-primary);
background: var(--color-bg-secondary, #f8f8f8);
padding: var(--spacing-sm) var(--spacing-md);
}
.ask-hit-card {
cursor: pointer;
transition: box-shadow 0.15s ease;
}
.ask-hit-card:hover,
.ask-hit-card:focus-visible {
box-shadow: 0 0 0 2px var(--color-primary);
outline: none;
}
.ask-history-btn {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View file

@ -671,6 +671,21 @@ export interface BuildRequest {
role_overrides: Record<string, string>
}
// ── Ask/RAG types ──────────────────────────────────────────────────────────
export interface AskRecipeHit {
id: number
title: string
match_pct: number | null
category: string | null
}
export interface AskResponse {
answer: string | null
recipes: AskRecipeHit[]
tier: string
}
// ========== Recipes API ==========
export const recipesAPI = {
@ -738,6 +753,12 @@ export const recipesAPI = {
return response.data
},
/** Natural-language recipe search with optional LLM synthesis (Paid tier). */
async ask(question: string, pantryItems: string[] = []): Promise<AskResponse> {
const response = await api.post('/recipes/ask', { question, pantry_items: pantryItems }, { timeout: 30000 })
return response.data
},
/** Stream a recipe via native SSE (Ollama fallback). Calls callbacks as tokens arrive. */
async suggestRecipeStream(
req: RecipeRequest,