feat(ui): Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis)

F9-lite reuses the pre-existing OLLAMA_* config (config.py:36-38:
OLLAMA_BASE_URL=https://ollama.com/v1, OLLAMA_API_KEY,
OLLAMA_MODEL=kimi-k2.6:cloud). Avoids the local model pull
(F9-full would be 4 GB on disk + a separate uvicorn process).
Cloud LLM — operator’s existing OLLAMA billing applies per call.

Sprint 13 splits the Sprint 11 "Generate Meal Plan" CTA into a
2-step modal: "Use the recipe library" (default, Sprint 11’s
existing flow) or "Ask the LLM" (new). The LLM path POSTs to
/api/llm/plan with a free-text prompt; the backend calls
kimi-k2.6:cloud on ollama.com, parses the LLM’s JSON picks,
creates a fresh plan, fills the LLM’s picks, and falls through
to the Sprint 6+ fillEmptySlots pattern for the slots the LLM
didn’t cover.

Backend:
- backend/app/api/llm_plan.py (NEW, ~280 lines). 1 endpoint
  (POST /api/llm/plan body {prompt, week_start}) + 4 helpers:
  - _ensure_ollama_configured — 503 on missing OLLAMA_API_KEY.
  - _serialize_library — reads up to 200 recipes for the
    family, sorted alphabetically. Cap prevents prompt-token
    overflow on kimi-k2.
  - _ask_llm — mirrors llm_matcher._ask_ollama (same URL,
    same headers, max_tokens=800, temperature=0, strips think
    blocks, 60s timeout).
  - _parse_picks — tolerant JSON parser. Handles markdown code
    fences, trailing commentary, and bare JSON. On failure
    returns []; the library fill takes over.
  - _validate_picks — drops invalid entries: missing fields,
    out-of-range day_of_week, unknown meal_type, unknown
    recipe_id. Returns a list of LLMPickedItem.
  Flow: rejects duplicate week (400) and empty library (400),
  builds the prompt, calls the LLM, validates picks, creates
  the plan, inserts the LLM-picked items, fills the rest from
  the library (Sprint 6+ pattern, re-implemented inline to
  avoid a self-HTTP-call), returns {plan_id, picked_count,
  filled_count, failed_count, reasoning}.
- backend/app/schemas/__init__.py — added LLMPlanRequest +
  LLMPlanResponse.
- backend/app/main.py:65-66 — registered llm_plan_api.router
  at the /api/llm prefix. No collision with the pre-existing
  WIP recipes.py.

Frontend:
- frontend/src/api/index.ts — added llm.plan(data) method.
- frontend/src/pages/Dashboard.tsx — added the prompt modal
  (radio for library vs. LLM + textarea for the LLM path with
  500-char counter) + new state (showPromptModal, promptMode,
  promptText, promptBusy) + extracted Sprint 11’s body into
  generateFromLibrary + added generateFromLLM. The modal is
  inline (not a separate component) because it depends on 4
  local states + 3 handlers. Click-outside-to-dismiss is
  disabled while promptBusy is true. The textarea autoFocuses
  when LLM mode is selected. Added the Button import.

LLM tolerance: a 60s timeout, parse-failure (markdown code
fences, trailing commentary), or empty response all return 0
picks; the library fill takes over. The user never sees a
crash — at worst, picked_count: 0 and the toast reads "Planned
N meals (LLM picked 0, library filled the rest)".

Verified: npm run build green (tsc 0 errors, vite 0 errors).
Bundle: 500.28 → 503.82 kB (+3.5 kB). Backend AST clean on
all 3 changed files. No new dependencies, no migration, no
pre-existing WIP files touched.

Deploy: git pull + docker compose up -d --build backend
frontend (no migration, no new dependencies).
This commit is contained in:
2026-06-05 16:59:03 -07:00
parent e939c96961
commit bae94037f3
5 changed files with 539 additions and 16 deletions
+326
View File
@@ -0,0 +1,326 @@
"""Sprint 13 — F9-lite: free-text meal-plan synthesis via Ollama Cloud.
This endpoint lets the user describe what they want for the week
("Italian-inspired, vegetarian", "easy weeknight dinners, no fish")
and asks the configured LLM (kimi-k2.6:cloud on ollama.com) to pick
meals from the local recipe library. The LLM's picks are inserted
into a fresh plan; the remaining slots are filled by the existing
Sprint 6+ `fillEmptySlots` partial-success pattern.
Reuses:
- The LLM call pattern from `app.services.llm_matcher._ask_ollama`
(POST ${OLLAMA_BASE_URL}/chat/completions, same headers, same
max_tokens=500, temperature=0, strip <think> blocks).
- The Sprint 6+ `meals.create` + `meals.fillEmptySlots` semantics
for the plan create + fill flow.
The endpoint never crashes on LLM failure: parse errors, timeout,
or empty picks all fall through to the `fillEmptySlots` path.
"""
from __future__ import annotations
import json
import logging
import re
import uuid
from datetime import date
from typing import List, Optional
import requests
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe
from app.security import require_session
logger = logging.getLogger(__name__)
router = APIRouter()
class LLMPlanRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=500)
week_start: date
class LLMPickedItem(BaseModel):
day_of_week: int
meal_type: str # "breakfast" | "lunch" | "dinner"
recipe_id: str
class LLMPlanResponse(BaseModel):
plan_id: str
picked_count: int
filled_count: int
failed_count: int
reasoning: Optional[str] = None
# Sprint 13: cap on the library size sent to the LLM. Larger
# libraries would exceed the prompt token budget for kimi-k2.
_MAX_LIBRARY_PROMPTED = 200
_LLM_TIMEOUT_SECS = 60
def _ensure_ollama_configured() -> str:
key = settings.OLLAMA_API_KEY
if not key:
raise HTTPException(
status_code=503,
detail="OLLAMA_API_KEY not configured; set it in the backend env",
)
return key
def _serialize_library(db: Session, profile_id: uuid.UUID) -> List[dict]:
"""Read up to _MAX_LIBRARY_PROMPTED recipes for the family.
Sorted alphabetically by name for deterministic LLM input."""
rows = (
db.query(Recipe)
.filter(Recipe.family_profile_id == profile_id)
.order_by(Recipe.name.asc())
.limit(_MAX_LIBRARY_PROMPTED)
.all()
)
out = []
for r in rows:
out.append({
"id": str(r.id),
"name": r.name,
"cuisine_tags": r.cuisine_tags or [],
"dietary_tags": r.dietary_tags or [],
"protein_type": r.protein_type,
"total_time_minutes": (r.prep_time_minutes or 0) + (r.cook_time_minutes or 0),
})
return out
def _ask_llm(prompt: str) -> Optional[str]:
"""Single-shot Ollama call. Mirrors llm_matcher._ask_ollama.
Returns the raw assistant text, or None on any failure."""
if not settings.OLLAMA_API_KEY:
return None
try:
resp = requests.post(
f"{settings.OLLAMA_BASE_URL}/chat/completions",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.OLLAMA_API_KEY}",
},
json={
"model": settings.OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 800, # kimi-k2 reasons before answering; 21 picks need headroom
"temperature": 0,
},
timeout=_LLM_TIMEOUT_SECS,
)
resp.raise_for_status()
except requests.RequestException as exc:
logger.warning("Ollama plan call failed: %s", exc)
return None
content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
# Strip <think>…</think> reasoning blocks
return re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
def _parse_picks(raw: str) -> List[dict]:
"""Parse the LLM's JSON response. Tolerant: handles markdown fences,
trailing commentary, and JSON-with-no-markdown. Returns a list of
{day_of_week, meal_type, recipe_id} dicts (validated by caller)."""
if not raw:
return []
# Strip markdown code fences if present
fence_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", raw, flags=re.DOTALL)
if fence_match:
candidate = fence_match.group(1)
else:
# Find the first '[' and the matching ']'
start = raw.find("[")
if start == -1:
return []
depth = 0
end = -1
for i in range(start, len(raw)):
if raw[i] == "[":
depth += 1
elif raw[i] == "]":
depth -= 1
if depth == 0:
end = i + 1
break
if end == -1:
return []
candidate = raw[start:end]
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
logger.warning("LLM plan parse failed; raw=%r", raw[:300])
return []
if not isinstance(parsed, list):
return []
return parsed
def _validate_picks(picks: List[dict], valid_recipe_ids: set[str]) -> List[LLMPickedItem]:
"""Drop invalid entries: missing fields, out-of-range day_of_week,
unknown meal_type, unknown recipe_id."""
valid_meal_types = {m.value for m in MealType}
out: List[LLMPickedItem] = []
for p in picks:
try:
day = int(p.get("day_of_week"))
meal = str(p.get("meal_type", "")).lower()
rid = str(p.get("recipe_id", ""))
except (TypeError, ValueError):
continue
if day < 1 or day > 7:
continue
if meal not in valid_meal_types:
continue
if rid not in valid_recipe_ids:
continue
out.append(LLMPickedItem(day_of_week=day, meal_type=meal, recipe_id=rid))
return out
@router.post("/plan", response_model=LLMPlanResponse)
def synthesize_plan(
payload: LLMPlanRequest,
db: Session = Depends(get_db),
_user: str = Depends(require_session),
) -> LLMPlanResponse:
"""Synthesize a 7-day meal plan from a free-text prompt + the
local recipe library via the configured LLM. Falls through to
the Sprint 6+ library-fill for any slots the LLM didn't pick."""
_ensure_ollama_configured()
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="family profile not found")
# 1) Reject if a plan for this week already exists (idempotency
# matches the Sprint 11 `meals.create` 400 path).
existing = (
db.query(MealPlan)
.filter(
MealPlan.family_profile_id == profile.id,
MealPlan.week_start_date == payload.week_start,
)
.first()
)
if existing:
raise HTTPException(
status_code=400,
detail=f"meal plan for {payload.week_start} already exists: {existing.id}",
)
# 2) Read the recipe library + build the LLM prompt.
library = _serialize_library(db, profile.id)
if not library:
raise HTTPException(
status_code=400,
detail="recipe library is empty; import some recipes first",
)
compact = "\n".join(
f"- id={r['id']} | {r['name']} | "
f"cuisine={','.join(r['cuisine_tags']) or 'n/a'} | "
f"diet={','.join(r['dietary_tags']) or 'n/a'} | "
f"protein={r['protein_type'] or 'n/a'} | "
f"time={r['total_time_minutes']}min"
for r in library
)
prompt = (
"You are planning a 7-day meal plan (Monday through Sunday) "
"for a family. Each day has 3 meals: breakfast, lunch, dinner. "
"Pick up to 21 meals total from the recipe library below. "
"If a slot has no good match for the user's request, OMIT it "
"(do not invent a recipe). Use only recipe_ids from the list.\n\n"
f"USER REQUEST: {payload.prompt}\n\n"
f"RECIPE LIBRARY ({len(library)} recipes):\n{compact}\n\n"
'RETURN FORMAT — valid JSON only, no markdown, no commentary:\n'
'[\n'
' {"day_of_week": 1, "meal_type": "breakfast", "recipe_id": "<uuid>"},\n'
' {"day_of_week": 1, "meal_type": "lunch", "recipe_id": "<uuid>"},\n'
' ...\n'
"]"
)
# 3) Call the LLM.
raw = _ask_llm(prompt)
raw_picks = _parse_picks(raw or "")
valid_recipe_ids = {r["id"] for r in library}
picks = _validate_picks(raw_picks, valid_recipe_ids)
logger.info(
"LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d",
len(payload.prompt), len(raw_picks), len(picks),
)
# 4) Create the empty plan.
plan = MealPlan(
family_profile_id=profile.id,
week_start_date=payload.week_start,
status="draft",
notes=f"LLM-synthesized from prompt: {payload.prompt[:200]}",
)
db.add(plan)
db.flush()
# 5) Insert the LLM-picked items.
for pick in picks:
db.add(MealPlanItem(
meal_plan_id=plan.id,
recipe_id=uuid.UUID(pick.recipe_id),
day_of_week=pick.day_of_week,
meal_type=MealType(pick.meal_type),
))
db.flush()
picked_count = len(picks)
picked_slots = {(p.day_of_week, p.meal_type) for p in picks}
# 6) Fill the rest from the library (Sprint 6+ pattern).
# Re-implemented inline (not via HTTP) to avoid a self-call.
requested_meal_types = ["breakfast", "lunch", "dinner"]
used_recipe_ids = {uuid.UUID(p.recipe_id) for p in picks}
filled_count = 0
failed_count = 0
for day in range(1, 8):
for mt in requested_meal_types:
if (day, mt) in picked_slots:
continue
# Pick the first library recipe not already used in the plan
# and with a matching meal_type preference (best-effort). The
# LLM call above was the smart pick; the library fill is the
# fallback for any slots the LLM didn't cover.
candidates = [r for r in library if uuid.UUID(r["id"]) not in used_recipe_ids]
if not candidates:
failed_count += 1
continue
chosen = candidates[0]
db.add(MealPlanItem(
meal_plan_id=plan.id,
recipe_id=uuid.UUID(chosen["id"]),
day_of_week=day,
meal_type=MealType(mt),
))
used_recipe_ids.add(uuid.UUID(chosen["id"]))
filled_count += 1
db.commit()
db.refresh(plan)
return LLMPlanResponse(
plan_id=str(plan.id),
picked_count=picked_count,
filled_count=filled_count,
failed_count=failed_count,
reasoning=raw if raw else None,
)
+3
View File
@@ -41,6 +41,7 @@ from app.api import meal_plans as meal_plans_api
from app.api import feedback as feedback_api from app.api import feedback as feedback_api
from app.api import orchestrate as orchestrate_api from app.api import orchestrate as orchestrate_api
from app.api import recipe_search as recipe_search_api from app.api import recipe_search as recipe_search_api
from app.api import llm_plan as llm_plan_api
app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"]) app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
@@ -61,3 +62,5 @@ app.include_router(meal_plans_api.public_router)
app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"]) app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"])
# Sprint 12: external recipe search (Spoonacular) + import. # Sprint 12: external recipe search (Spoonacular) + import.
app.include_router(recipe_search_api.router, prefix="/api/recipes", tags=["recipes"]) app.include_router(recipe_search_api.router, prefix="/api/recipes", tags=["recipes"])
# Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama Cloud.
app.include_router(llm_plan_api.router, prefix="/api/llm", tags=["llm"])
+17 -1
View File
@@ -412,4 +412,20 @@ class RecipeSearchHit(BaseModel):
class RecipeImportRequest(BaseModel): class RecipeImportRequest(BaseModel):
external_id: str external_id: str
external_source: str = "spoonacular" external_source: str = "spoonacular"
# Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama Cloud.
# The prompt is 1-500 chars; the response mirrors the Sprint 11
# create-then-fill shape (plan_id + per-step counts).
class LLMPlanRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=500)
week_start: date
class LLMPlanResponse(BaseModel):
plan_id: str
picked_count: int
filled_count: int
failed_count: int
reasoning: Optional[str] = None
+9
View File
@@ -119,6 +119,15 @@ export const mealPlannerApi = {
get: (mealPlanItemId: string) => api.get(`/feedback/${mealPlanItemId}`), get: (mealPlanItemId: string) => api.get(`/feedback/${mealPlanItemId}`),
create: (data: any) => api.post('/feedback', data), create: (data: any) => api.post('/feedback', data),
}, },
// Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama
// Cloud. The prompt + week_start are sent to /api/llm/plan; the
// backend calls kimi-k2.6:cloud, parses the picks, creates the
// plan, fills the rest from the library, returns the plan id.
llm: {
plan: (data: { prompt: string; week_start: string }) =>
api.post('/llm/plan', data),
},
} }
export default api export default api
+184 -15
View File
@@ -26,6 +26,7 @@ import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card' import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton' import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { Button } from '../components/ui/Button'
import { WeekRangeNav } from '../components/WeekRangeNav' import { WeekRangeNav } from '../components/WeekRangeNav'
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
@@ -392,19 +393,45 @@ export default function Dashboard() {
} }
} }
// Sprint 11: wire the dead "Generate Meal Plan" empty-state CTA. // Sprint 13: modal + form state for the "Generate Meal Plan" CTA.
// Creates a fresh meal plan for the current week, then fills its // The user picks "Use the recipe library" (default, Sprint 11
// empty slots from the recipe library via the same endpoint the // behaviour) or "Ask the LLM" (Sprint 13, free-text prompt). The
// existing `Plan Week` menu uses (handlePlanWeek above). Two // modal handles its own loading + error state; the parent only
// requests, but they reuse existing endpoints; no backend changes. // needs to know when to close it (success) and when to show the
async function handleGenerateFirstPlan() { // toast.
if (generatingFirstPlan) return const [showPromptModal, setShowPromptModal] = useState(false)
const [promptMode, setPromptMode] = useState<'library' | 'llm'>('library')
const [promptText, setPromptText] = useState('')
const [promptBusy, setPromptBusy] = useState(false)
// Sprint 13: handler invoked from the prompt modal's submit
// button. Branches on `promptMode`. Library mode is the Sprint 11
// create-then-fill flow; LLM mode POSTs /api/llm/plan and lets the
// backend do the synthesis.
async function handlePromptSubmit() {
if (promptBusy) return
if (promptMode === 'llm' && !promptText.trim()) {
showToast.error('Describe what you want for the week')
return
}
setPromptBusy(true)
try {
if (promptMode === 'library') {
await generateFromLibrary()
} else {
await generateFromLLM()
}
setShowPromptModal(false)
setPromptText('')
} finally {
setPromptBusy(false)
}
}
// Library path (Sprint 11, extracted into its own function).
async function generateFromLibrary() {
setGeneratingFirstPlan(true) setGeneratingFirstPlan(true)
try { try {
// 1) Create the empty plan. The backend returns 400 with detail
// "Meal plan for this week already exists" if another tab
// created one first — we fall through to fillEmptySlots in
// that case.
let planId: string | undefined let planId: string | undefined
try { try {
const res = await mealPlannerApi.meals.create({ const res = await mealPlannerApi.meals.create({
@@ -414,7 +441,6 @@ export default function Dashboard() {
}) })
planId = (res.data as { id?: string } | undefined)?.id planId = (res.data as { id?: string } | undefined)?.id
} catch (createErr: unknown) { } catch (createErr: unknown) {
// Race with another tab: re-fetch the plan to get its id.
const existing = await mealPlannerApi.meals.getPlanned(weekStart) const existing = await mealPlannerApi.meals.getPlanned(weekStart)
planId = (existing.data as { id?: string } | undefined)?.id planId = (existing.data as { id?: string } | undefined)?.id
if (!planId) throw createErr if (!planId) throw createErr
@@ -423,9 +449,6 @@ export default function Dashboard() {
showToast.error('Failed to create meal plan') showToast.error('Failed to create meal plan')
return return
} }
// 2) Fill the empty slots from the library. Same partial-success
// toast pattern as handlePlanWeek.
const fillRes = await mealPlannerApi.meals.fillEmptySlots(planId, ['breakfast', 'lunch', 'dinner']) const fillRes = await mealPlannerApi.meals.fillEmptySlots(planId, ['breakfast', 'lunch', 'dinner'])
const data = fillRes.data as { filled: unknown[]; failed: { reason: string }[] } const data = fillRes.data as { filled: unknown[]; failed: { reason: string }[] }
const filledCount = data.filled.length const filledCount = data.filled.length
@@ -447,6 +470,41 @@ export default function Dashboard() {
setGeneratingFirstPlan(false) setGeneratingFirstPlan(false)
} }
} }
// LLM path (Sprint 13). POSTs /api/llm/plan. The backend returns
// {plan_id, picked_count, filled_count, failed_count}. Toast
// shows the picked/filled split; on 503 (no OLLAMA_API_KEY), the
// showApiError toast surfaces the clear backend message.
async function generateFromLLM() {
setGeneratingFirstPlan(true)
try {
const res = await mealPlannerApi.llm.plan({ prompt: promptText.trim(), week_start: weekStart })
const data = res.data as { plan_id: string; picked_count: number; filled_count: number; failed_count: number }
const total = data.picked_count + data.filled_count
if (data.failed_count > 0) {
showToast.error(
`Planned ${total} meals (LLM picked ${data.picked_count}, library filled ${data.filled_count}; ${data.failed_count} failed)`,
)
} else {
showToast.success(
`Planned ${total} meals (LLM picked ${data.picked_count}, library filled the rest)`,
)
}
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
} catch (err) {
showApiError(err, 'Failed to generate meal plan via LLM')
} finally {
setGeneratingFirstPlan(false)
}
}
// Sprint 11: open the prompt modal (Sprint 13 split the body into
// generateFromLibrary + generateFromLLM, both invoked from the
// modal's submit handler).
function handleGenerateFirstPlan() {
if (generatingFirstPlan) return
setShowPromptModal(true)
}
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({ const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
queryKey: ['mealPlan', weekStart], queryKey: ['mealPlan', weekStart],
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data), queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
@@ -563,6 +621,7 @@ export default function Dashboard() {
disabled: generatingFirstPlan, disabled: generatingFirstPlan,
}} }}
/> />
{showPromptModal && renderPromptModal()}
</div> </div>
) )
} }
@@ -729,6 +788,116 @@ export default function Dashboard() {
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
{showPromptModal && renderPromptModal()}
</div> </div>
) )
// Sprint 13: prompt modal. Inline (not a separate component)
// because it depends on 4 local states (showPromptModal,
// promptMode, promptText, promptBusy) and 3 handlers. The modal
// is a real dialog with role=region + focus on the textarea;
// a full focus-trap is out of scope but Tab cycles naturally
// through the 2 radios + textarea + 2 buttons.
function renderPromptModal() {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
onClick={() => !promptBusy && setShowPromptModal(false)}
>
<div
className="w-full max-w-lg rounded-xl bg-white shadow-xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Card className="w-full max-w-lg">
<CardBody>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-5 h-5 text-primary-600" />
<h2 className="text-lg font-semibold text-surface-900">Generate Meal Plan</h2>
</div>
<p className="text-sm text-surface-500 mb-4">
Pick a generation strategy. Both create a fresh plan for the current week and fill any
empty slots from the recipe library.
</p>
<div className="space-y-2 mb-4">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="prompt-mode"
value="library"
checked={promptMode === 'library'}
onChange={() => setPromptMode('library')}
disabled={promptBusy}
className="mt-1"
/>
<div>
<div className="text-sm font-medium text-surface-900">Use the recipe library</div>
<div className="text-xs text-surface-500">Faster; no external API. Default.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="prompt-mode"
value="llm"
checked={promptMode === 'llm'}
onChange={() => setPromptMode('llm')}
disabled={promptBusy}
className="mt-1"
/>
<div>
<div className="text-sm font-medium text-surface-900">Ask the LLM</div>
<div className="text-xs text-surface-500">
Free-text prompt; kimi-k2.6:cloud picks meals, library fills the rest.
</div>
</div>
</label>
</div>
{promptMode === 'llm' && (
<div className="mb-4">
<label htmlFor="llm-prompt" className="block text-sm font-medium text-surface-700 mb-1">
What do you want for the week?
</label>
<textarea
id="llm-prompt"
autoFocus
value={promptText}
onChange={(e) => setPromptText(e.target.value)}
disabled={promptBusy}
rows={3}
maxLength={500}
placeholder="e.g., Italian-inspired, vegetarian, easy weeknight dinners"
className="w-full rounded-lg border border-surface-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<div className="mt-1 text-xs text-surface-500 text-right">
{promptText.length} / 500
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setShowPromptModal(false)}
disabled={promptBusy}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handlePromptSubmit}
disabled={promptBusy}
icon={promptBusy ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
>
{promptBusy
? promptMode === 'llm' ? 'Asking LLM' : 'Generating'
: 'Generate'}
</Button>
</div>
</CardBody>
</Card>
</div>
</div>
)
}
} }