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"
/>
+
+
@@ -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,