From 59b183a898c6a7f396300024e8270e817b269584 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 11 May 2026 13:08:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(ask):=20Add=20Ask=20tab=20=E2=80=94=20natu?= =?UTF-8?q?ral-language=20recipe=20search=20with=20session=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/RecipesView.vue | 287 +++++++++++++++++++++--- frontend/src/services/api.ts | 21 ++ 2 files changed, 272 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue index b37717b..562b8cc 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -89,6 +89,118 @@ tabindex="0" /> + +
+
+

Ask about recipes

+

Search by ingredient, dish type, or any cooking question.

+ + +
+ + +
+ + +
+

Try asking:

+
+ +
+
+ + + +
+ + +
+ +
+

{{ askResult.answer }}

+
+ + +
+

+ {{ askResult.recipes.length }} recipe{{ askResult.recipes.length !== 1 ? 's' : '' }} found +

+
+
+

{{ hit.title }}

+
+ {{ hit.category }} + {{ Math.round(hit.match_pct * 100) }}% pantry match +
+
+
+
+ + +
+

No recipes found for this question. Try different keywords.

+
+
+ + +
+
+

Recent questions

+ +
+
+ +
+
+
+
@@ -779,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 ──────────────────────────────────────────────────────────────── @@ -810,11 +922,11 @@ const parsedStream = computed((): ParsedStreamRecipe => { if (!text) return { title: null, ingredients: [], steps: [], notes: null } const titleMatch = text.match(/^Title:\s*(.+)$/m) - const title = titleMatch ? titleMatch[1].trim() : null + const title = titleMatch?.[1]?.trim() ?? null const ingMatch = text.match(/^Ingredients:\s*(.+)$/m) - const ingredients = ingMatch - ? ingMatch[1].split(',').map((s) => s.trim()).filter(Boolean) + const ingredients = ingMatch?.[1] + ? ingMatch[1]!.split(',').map((s) => s.trim()).filter(Boolean) : [] const steps: string[] = [] @@ -823,12 +935,12 @@ const parsedStream = computed((): ParsedStreamRecipe => { 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()) + steps.push(m[1]?.trim() ?? '') } } const notesMatch = text.match(/^Notes:\s*([\s\S]+)$/m) - const notes = notesMatch ? notesMatch[1].trim() : null + const notes = notesMatch?.[1]?.trim() ?? null return { title, ingredients, steps, notes } }) @@ -837,21 +949,23 @@ 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('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(null) +const askPanelRef = ref(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() @@ -871,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() @@ -1117,6 +1233,32 @@ 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>(() => { + const result: Record = {} + 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 { @@ -1210,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>(() => { - const result: Record = {} - 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() @@ -1374,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(null) +const askResult = ref(null) +const askHistory = ref([]) + +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() @@ -2180,4 +2340,59 @@ details[open] .collapsible-summary::before { 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; +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ad20fa7..f618d95 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -671,6 +671,21 @@ export interface BuildRequest { role_overrides: Record } +// ── 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 { + 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,