Compare commits

..

No commits in common. "59b183a898c6a7f396300024e8270e817b269584" and "ac4eda2047056cc3d7900c155b3a34ab0e0cb228" have entirely different histories.

4 changed files with 42 additions and 675 deletions

View file

@ -16,9 +16,6 @@ log = logging.getLogger(__name__)
from app.db.session import get_store from app.db.session import get_store
from app.db.store import Store from app.db.store import Store
from app.models.schemas.recipe import ( from app.models.schemas.recipe import (
AskRequest,
AskResponse,
AskRecipeHit,
AssemblyTemplateOut, AssemblyTemplateOut,
BuildRequest, BuildRequest,
LeftoversResponse, LeftoversResponse,
@ -600,137 +597,6 @@ async def build_recipe(
return result 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}") @router.get("/{recipe_id}")
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict: async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
def _get(db_path: Path, rid: int) -> dict | None: def _get(db_path: Path, rid: int) -> dict | None:

View file

@ -206,24 +206,3 @@ class StreamTokenResponse(BaseModel):
stream_url: str stream_url: str
token: str token: str
expires_in_s: int 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,118 +89,6 @@
tabindex="0" 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) --> <!-- Find tab (existing search UI) -->
<div v-else ref="findPanelRef" role="tabpanel" aria-labelledby="tab-find" tabindex="0"> <div v-else ref="findPanelRef" role="tabpanel" aria-labelledby="tab-find" tabindex="0">
<!-- Controls Panel --> <!-- Controls Panel -->
@ -259,19 +147,6 @@
Tap "Find recipes" again to apply. Tap "Find recipes" again to apply.
</p> </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 --> <!-- Time Budget selector always visible; closes #131 -->
<div class="form-group time-bucket-group"> <div class="form-group time-bucket-group">
<!-- Hands-on / active time row --> <!-- Hands-on / active time row -->
@ -525,8 +400,6 @@
</div> </div>
</details> </details>
</div><!-- end #refine-panel -->
<!-- Suggest Button --> <!-- Suggest Button -->
<div class="suggest-row"> <div class="suggest-row">
<button <button
@ -572,50 +445,12 @@
<!-- Streaming recipe generation panel --> <!-- Streaming recipe generation panel -->
<div v-if="isStreaming || streamChunks || streamError" class="stream-panel"> <div v-if="isStreaming || streamChunks || streamError" class="stream-panel">
<div v-if="isStreaming" class="stream-status" role="status"> <div v-if="isStreaming" class="stream-status">
<span class="stream-dot" aria-hidden="true"></span> <span class="stream-dot" aria-hidden="true"></span>
Generating recipe Generating recipe
</div> </div>
<div v-if="streamError" class="stream-error text-sm" role="alert">{{ streamError }}</div> <div v-if="streamError" class="stream-error text-sm">{{ streamError }}</div>
<pre v-if="streamChunks" class="stream-output">{{ streamChunks }}</pre>
<!-- 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> </div>
<!-- Screen reader announcement for loading + results --> <!-- Screen reader announcement for loading + results -->
@ -891,7 +726,7 @@ import BuildYourOwnTab from './BuildYourOwnTab.vue'
import OrchUsagePill from './OrchUsagePill.vue' import OrchUsagePill from './OrchUsagePill.vue'
import RecipeScanModal from './RecipeScanModal.vue' import RecipeScanModal from './RecipeScanModal.vue'
import type { ForkResult } from '../stores/community' import type { ForkResult } from '../stores/community'
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse, AskResponse } from '../services/api' import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
import { recipesAPI } from '../services/api' import { recipesAPI } from '../services/api'
// Scan modal // Scan modal
@ -910,62 +745,25 @@ const isStreaming = ref(false)
const streamChunks = ref('') const streamChunks = ref('')
const streamError = ref<string | null>(null) 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 recipesStore = useRecipesStore()
const inventoryStore = useInventoryStore() const inventoryStore = useInventoryStore()
// Tab state // Tab state
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build' | 'ask' type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
const tabs: Array<{ id: TabId; label: string; mobileLabel?: string }> = [ const tabs: Array<{ id: TabId; label: string; mobileLabel?: string }> = [
{ id: 'saved', label: 'Saved' }, { id: 'saved', label: 'Saved' },
{ id: 'build', label: 'Build Your Own', mobileLabel: 'Build' }, { id: 'build', label: 'Build Your Own', mobileLabel: 'Build' },
{ id: 'community', label: 'Community' }, { id: 'community', label: 'Community' },
{ id: 'find', label: 'Find' }, { id: 'find', label: 'Find' },
{ id: 'browse', label: 'Browse' }, { id: 'browse', label: 'Browse' },
{ id: 'ask', label: 'Ask' },
] ]
const activeTab = ref<TabId>('saved') const activeTab = ref<TabId>('saved')
const savedStore = useSavedRecipesStore() const savedStore = useSavedRecipesStore()
// Template refs for panels that need explicit focus management on tab switch // Template ref for the Find-tab panel div (used for focus management on tab switch)
const findPanelRef = ref<HTMLElement | null>(null) const findPanelRef = ref<HTMLElement | null>(null)
const askPanelRef = ref<HTMLElement | null>(null)
function onTabKeydown(e: KeyboardEvent) { function onTabKeydown(e: KeyboardEvent) {
const tabIds: TabId[] = ['saved', 'build', 'community', 'find', 'browse', 'ask'] const tabIds: TabId[] = ['saved', 'build', 'community', 'find', 'browse']
const current = tabIds.indexOf(activeTab.value) const current = tabIds.indexOf(activeTab.value)
if (e.key === 'ArrowRight') { if (e.key === 'ArrowRight') {
e.preventDefault() e.preventDefault()
@ -985,8 +783,6 @@ async function activateTab(tab: TabId) {
// components so we locate their panel via querySelector. // components so we locate their panel via querySelector.
if (tab === 'find' && findPanelRef.value) { if (tab === 'find' && findPanelRef.value) {
findPanelRef.value.focus() findPanelRef.value.focus()
} else if (tab === 'ask' && askPanelRef.value) {
askPanelRef.value.focus()
} else { } else {
const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null const panel = document.querySelector('[role="tabpanel"]') as HTMLElement | null
panel?.focus() panel?.focus()
@ -1189,14 +985,6 @@ const advancedActive = computed(() =>
!!recipesStore.styleId !!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(() => { const activeFilterCount = computed(() => {
let n = 0 let n = 0
if (recipesStore.constraints.length > 0) n++ if (recipesStore.constraints.length > 0) n++
@ -1233,92 +1021,6 @@ function clearAllFindFilters() {
categoryInput.value = '' 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 // #46 count of active nutrition filters so the summary is informative when collapsed
const activeNutritionFilterCount = computed(() => const activeNutritionFilterCount = computed(() =>
Object.values(recipesStore.nutritionFilters ?? {}).filter((v) => v !== null).length Object.values(recipesStore.nutritionFilters ?? {}).filter((v) => v !== null).length
@ -1352,6 +1054,33 @@ const cuisineStyles = [
{ id: 'eastern_european', label: 'Eastern European' }, { 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 // Tag input helpers constraints
function addConstraint(value: string) { function addConstraint(value: string) {
const tag = value.trim().toLowerCase() const tag = value.trim().toLowerCase()
@ -1489,51 +1218,6 @@ async function handleLoadMore() {
isLoadingMore.value = false 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 () => { onMounted(async () => {
if (inventoryStore.items.length === 0) { if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems() await inventoryStore.fetchItems()
@ -1768,35 +1452,6 @@ watch(
color: var(--color-text-secondary); 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 { .filter-bar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -2274,125 +1929,13 @@ details[open] .collapsible-summary::before {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
/* ── Stream recipe card — skeleton + progressive reveal ─────────────────── */ .stream-output {
.stream-recipe-card { font-family: inherit;
margin-top: var(--spacing-sm); white-space: pre-wrap;
} font-size: var(--font-size-sm);
color: var(--color-text);
.stream-section-label { margin: 0;
display: block; max-height: 400px;
color: var(--color-text-secondary); overflow-y: auto;
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> </style>

View file

@ -671,21 +671,6 @@ export interface BuildRequest {
role_overrides: Record<string, string> 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 ========== // ========== Recipes API ==========
export const recipesAPI = { export const recipesAPI = {
@ -753,12 +738,6 @@ export const recipesAPI = {
return response.data 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. */ /** Stream a recipe via native SSE (Ollama fallback). Calls callbacks as tokens arrive. */
async suggestRecipeStream( async suggestRecipeStream(
req: RecipeRequest, req: RecipeRequest,