Compare commits

..

9 commits

Author SHA1 Message Date
3f4b756fc6 feat(find): surface time budget inline, always visible (closes #131)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
The time budget selector (hands-on and total time chips) was previously
gated behind the time_first_layout Settings preference. Removed the v-if
guard so both rows are always visible in the Find tab without requiring
a Settings change.

Added "No limit" clear buttons that appear next to the chip row when a
time limit is active, so users can reset a time filter in one tap without
needing to find the active chip and re-tap it.

The time_first_layout setting in Settings remains for users who want
control over the layout.
2026-05-11 12:11:06 -07:00
973c76a4c8 feat(browse): add breadcrumb nav above recipe grid (closes #130)
Renders domain › category › subcategory above the recipe grid whenever
a domain and category are active. Each ancestor crumb is a button that
navigates back up the hierarchy (selectDomain / selectCategory). The
leaf node is a plain span with aria-current="page". The nav has
aria-label="Browse location" for screen reader context.
2026-05-11 11:58:49 -07:00
92fab94ae0 feat(find): active-filter bar with clear-all (closes #129)
Adds a summary bar that appears at the top of the Find Recipes panel
whenever any filter is active. Shows a count ("3 filters active") and
a Clear all button that resets all Find-tab filters in one tap:
  constraints, allergies, excluded ingredients, shopping mode,
  pantry-match-only, hard day mode, time budgets (active + total),
  max missing, style, category, and all four nutrition limits.

Local input refs (constraintInput, allergyInput, etc.) are also cleared
so the text fields don't show stale uncommitted values after a clear.
2026-05-11 11:57:10 -07:00
30f5620fd5 feat(settings): autosave on change, remove Save buttons (closes #128)
Each setting now saves via a debounced (600ms) individual API call when
its value changes. A hydration guard (_hydrated flag + nextTick) prevents
watchers from firing during the initial load() fetch, ensuring the first
API round-trip does not generate spurious write calls.

Removed: five explicit Save buttons across Equipment, Sensory, Units,
Shopping Region, and Recipe Search Layout sections.
Added: "Changes save automatically." subtitle + fixed bottom-right toast
  that appears for 2s after any successful save, with enter/leave
  transitions that respect prefers-reduced-motion via the theme.

The full save() and saveSensory() actions are kept as internal fallbacks.
2026-05-11 11:55:09 -07:00
0ef57618bf fix(a11y): add aria-pressed and aria-label to Browse panel buttons (WCAG 2.1)
Screen readers had no way to determine which domain, category, subcategory,
or sort button was selected — the active CSS class is invisible to assistive
technology.

  - aria-pressed on all toggle buttons (domain, category, subcategory, sort)
  - aria-label="Previous page" / "Next page" on pagination buttons
  - aria-live="polite" on results count span — announces filter result changes
  - Equipment chip-remove: "Remove" → "Remove equipment: {item}"

Addresses WCAG 2.1 AA criteria 4.1.2 (Name, Role, Value) and 1.3.1
(Info and Relationships). Part of kiwi UX audit (2026-05-11).
2026-05-11 11:33:10 -07:00
8c765b7da2 fix(barcode): look up product info before checking auto_add_to_inventory
Previously, get_or_create_product was only called when auto_add was true,
so scan responses with auto_add=false returned no product details. Now the
DB lookup always runs when product_info is available; inventory insertion
is still conditional on auto_add_to_inventory. Fixes preview-only barcode
scans returning empty product fields.
2026-05-11 11:33:02 -07:00
e57f46f4b6 feat(streaming): add native SSE fallback for L3/L4 recipe generation (closes #126)
Two-phase streaming architecture:
  Phase 1 (sync thread): IngredientClassifier builds element profiles +
    gap list from SQLite — thread-safe, no async context needed
  Phase 2 (async): LLMRecipeGenerator.stream_generate() yields tokens via
    cf-orch warm vllm (existing /stream-token path) or AsyncOpenAI against
    Ollama if the coordinator is unavailable

Backend (app/services/recipe/llm_recipe.py):
  - stream_generate() async generator; _try_alloc_for_stream() sync helper
  - _stream_openai_compat() static method handles __auto__ model resolution
  - LLMRecipeGenerator(None) is safe for streaming (store not used)

Endpoint (app/api/endpoints/recipes.py):
  - ?stream=true on POST /recipes/suggest returns StreamingResponse
  - X-Accel-Buffering: no prevents nginx buffering without nginx.conf edits

Frontend (api.ts, recipes.ts, RecipesView.vue):
  - suggestRecipeStream() uses fetch + ReadableStream (POST; EventSource
    only supports GET)
  - streamSuggest() action in recipes store builds request internally
  - RecipesView.streamRecipe() silently falls back to native SSE when
    cf-orch token fetch fails rather than surfacing an error
2026-05-11 11:32:54 -07:00
04dbdddbad feat(mcp): add Kiwi MCP server for corpus DB access (closes #124)
Exposes four read-only tools to Claude Code:
  kiwi_query_corpus   — parameterised SELECT against kiwi.db (200-row cap)
  kiwi_count_fts      — FTS5 MATCH hit count for keyword coverage audits
  kiwi_sample_tags    — tag frequency distribution by prefix
  kiwi_browse_preview — first-page results from the live browse API

DB opened in SQLite URI read-only mode (mode=ro); any write statement is
rejected at the driver level. Configure via KIWI_DB_PATH and KIWI_API_URL
env vars (see module docstring for settings.json snippet).
2026-05-11 11:32:40 -07:00
e83bb0415a feat(manage): add update and cloud-update commands (closes #127)
Adds `update` (local stack) and `cloud-update` (menagerie) subcommands
to manage.sh. Both pull HEAD and rebuild/restart the Docker stack in one
step — required for post-merge deployment without manual compose commands.
2026-05-11 11:32:30 -07:00
13 changed files with 840 additions and 148 deletions

View file

@ -478,7 +478,8 @@ async def scan_barcode_image(
from app.services.openfoodfacts import OpenFoodFactsService
from app.services.expiration_predictor import ExpirationPredictor
barcodes = await asyncio.to_thread(BarcodeScanner().scan_image, temp_file)
image_bytes = temp_file.read_bytes()
barcodes = await asyncio.to_thread(BarcodeScanner().scan_from_bytes, image_bytes)
if not barcodes:
return BarcodeScanResponse(
success=False, barcodes_found=0, results=[],
@ -500,9 +501,10 @@ async def scan_barcode_image(
product_info = await off.lookup_product(code)
product_source = "openfoodfacts"
db_product = None
inventory_item = None
if product_info and auto_add_to_inventory:
product, _ = await asyncio.to_thread(
if product_info:
db_product, _ = await asyncio.to_thread(
store.get_or_create_product,
product_info.get("name", code),
code,
@ -512,6 +514,7 @@ async def scan_barcode_image(
source=product_source,
source_data=product_info,
)
if auto_add_to_inventory:
exp = predictor.predict_expiration(
product_info.get("category", ""),
location,
@ -523,18 +526,18 @@ async def scan_barcode_image(
resolved_unit = product_info.get("pack_unit") or "count"
inventory_item = await asyncio.to_thread(
store.add_inventory_item,
product["id"], location,
db_product["id"], location,
quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None,
source="barcode_scan",
)
product_found = product_info is not None
product_found = db_product is not None
needs_capture = not product_found and has_visual_capture
results.append({
"barcode": code,
"barcode_type": bc.get("type", "unknown"),
"product": ProductResponse.model_validate(product_info) if product_info else None,
"product": ProductResponse.model_validate(db_product) if db_product else None,
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
"added_to_inventory": inventory_item is not None,
"needs_manual_entry": not product_found and not needs_capture,

View file

@ -6,7 +6,9 @@ import logging
from pathlib import Path
from typing import Annotated
import json as _json_mod
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from app.cloud_session import CloudUser, _auth_label, get_session
@ -103,6 +105,39 @@ def _build_stream_prompt(db_path: Path, level: int) -> str:
store.close()
async def _stream_recipe_sse(db_path: Path, req: RecipeRequest):
"""Async generator that yields SSE events for a streaming recipe request.
Phase 1 (thread): classify pantry items using a temporary Store.
Phase 2 (async): stream tokens from LLM via LLMRecipeGenerator.stream_generate().
"""
def _prep(db_path: Path) -> tuple[list, list[str]]:
from app.services.recipe.element_classifier import IngredientClassifier
store = Store(db_path)
try:
classifier = IngredientClassifier(store)
profiles = classifier.classify_batch(req.pantry_items)
gaps = classifier.identify_gaps(profiles)
return profiles, gaps
finally:
store.close()
try:
profiles, gaps = await asyncio.to_thread(_prep, db_path)
except Exception as exc:
yield f"data: {_json_mod.dumps({'error': str(exc)})}\n\n"
return
from app.services.recipe.llm_recipe import LLMRecipeGenerator
gen = LLMRecipeGenerator(None)
try:
async for token in gen.stream_generate(req, profiles, gaps):
yield f"data: {_json_mod.dumps({'chunk': token})}\n\n"
yield f"data: {_json_mod.dumps({'done': True})}\n\n"
except Exception as exc:
yield f"data: {_json_mod.dumps({'error': str(exc)})}\n\n"
async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
"""Queue an async recipe_llm job and return 202 with job_id.
@ -144,6 +179,7 @@ async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
async def suggest_recipes(
req: RecipeRequest,
async_mode: bool = Query(default=False, alias="async"),
stream: bool = Query(default=False),
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
):
@ -179,6 +215,13 @@ async def suggest_recipes(
req = req.model_copy(update={"level": 2})
orch_fallback = True
if stream and req.level in (3, 4):
return StreamingResponse(
_stream_recipe_sse(session.db, req),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
if req.level in (3, 4) and async_mode:
return await _enqueue_recipe_job(session, req)

0
app/mcp/__init__.py Normal file
View file

306
app/mcp/server.py Normal file
View file

@ -0,0 +1,306 @@
"""Kiwi MCP Server — read-only corpus DB access for tag/keyword audits.
Exposes four tools to Claude:
kiwi_query_corpus run a read-only SQL query against the corpus DB
kiwi_count_fts run an FTS5 MATCH expression and return row count
kiwi_sample_tags return tag frequency distribution by prefix
kiwi_browse_preview call the browse endpoint and return first-page results
Run with:
python -m app.mcp.server
(from /Library/Development/CircuitForge/kiwi with cf conda env active)
Configure in Claude Code ~/.claude/settings.json mcpServers:
"kiwi": {
"command": "/devl/miniconda3/envs/cf/bin/python",
"args": ["-m", "app.mcp.server"],
"cwd": "/Library/Development/CircuitForge/kiwi",
"env": {
"KIWI_DB_PATH": "/Library/Development/CircuitForge/kiwi/data/kiwi.db",
"KIWI_API_URL": "http://localhost:8512"
}
}
"""
from __future__ import annotations
import asyncio
import json
import os
import sqlite3
from pathlib import Path
import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
_DB_PATH = os.environ.get(
"KIWI_DB_PATH",
str(Path(__file__).parents[3] / "data" / "kiwi.db"),
)
_API_URL = os.environ.get("KIWI_API_URL", "http://localhost:8512")
_TIMEOUT = 30.0
_QUERY_ROW_LIMIT = 200
server = Server("kiwi")
def _open_ro() -> sqlite3.Connection:
"""Open the corpus DB in read-only mode."""
uri = f"file:///{Path(_DB_PATH).as_posix()}?mode=ro"
conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="kiwi_query_corpus",
description=(
"Run a read-only SQL SELECT query against the Kiwi corpus DB (kiwi.db). "
"Returns up to 200 rows as a JSON array. "
"Key tables: recipes (id, title, ingredient_names, inferred_tags, source_url), "
"recipes_fts (FTS5 virtual table for full-text search), "
"ingredient_profiles (name, elements, texture_profile). "
"Use for schema exploration, spot-checking tag coverage, and counting results. "
"Read-only — any write statement will be rejected by SQLite."
),
inputSchema={
"type": "object",
"required": ["sql"],
"properties": {
"sql": {
"type": "string",
"description": (
"A SELECT statement. E.g.: "
"SELECT title, inferred_tags FROM recipes WHERE inferred_tags LIKE '%vegan%' LIMIT 10"
),
},
},
},
),
Tool(
name="kiwi_count_fts",
description=(
"Run an FTS5 MATCH expression against the recipes_fts table and return the hit count. "
"Useful for quickly auditing keyword coverage without a full query. "
"Always double-quote all terms in MATCH expressions. "
"E.g. match_expr='\"tofu\" OR \"tempeh\"' returns how many recipes include either."
),
inputSchema={
"type": "object",
"required": ["match_expr"],
"properties": {
"match_expr": {
"type": "string",
"description": (
"FTS5 MATCH expression string (without the MATCH keyword). "
'E.g. \'"lentil" OR "chickpea"\' or \'"pasta" AND "vegetarian"\''
),
},
},
},
),
Tool(
name="kiwi_sample_tags",
description=(
"Return tag frequency distribution from the corpus. "
"Queries inferred_tags column for tags matching the given prefix pattern. "
"Useful for auditing how well a category keyword set covers the corpus, "
"or discovering what tags exist under a domain (cuisine:, meal:, dietary:, texture:)."
),
inputSchema={
"type": "object",
"properties": {
"prefix": {
"type": "string",
"default": "",
"description": (
"Tag prefix to filter by. E.g. 'cuisine:' returns all cuisine tags, "
"'meal:' returns all meal type tags, '' returns all tags. "
"Returns top 50 by frequency."
),
},
"limit": {
"type": "integer",
"default": 50,
"description": "Max number of tag entries to return (default 50, max 200).",
},
},
},
),
Tool(
name="kiwi_browse_preview",
description=(
"Call the Kiwi browse endpoint and return first-page results. "
"Use to verify that a domain/category returns the expected recipes "
"after a keyword or tag change, without opening the browser. "
"Returns recipe titles, match counts, and total result count."
),
inputSchema={
"type": "object",
"required": ["domain", "category"],
"properties": {
"domain": {
"type": "string",
"description": (
"Browse domain slug. "
"Known domains: cuisine, meal_type, dietary, ingredient, occasion, texture."
),
},
"category": {
"type": "string",
"description": "Category slug within the domain, e.g. 'italian', 'breakfast', 'vegan'.",
},
"subcategory": {
"type": "string",
"default": "",
"description": "Optional subcategory slug to narrow further.",
},
"page_size": {
"type": "integer",
"default": 10,
"description": "Results per page (default 10, max 50).",
},
},
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "kiwi_query_corpus":
return await _query_corpus(arguments)
if name == "kiwi_count_fts":
return await _count_fts(arguments)
if name == "kiwi_sample_tags":
return await _sample_tags(arguments)
if name == "kiwi_browse_preview":
return await _browse_preview(arguments)
return [TextContent(type="text", text=f"Unknown tool: {name}")]
async def _query_corpus(args: dict) -> list[TextContent]:
sql = args.get("sql", "").strip()
if not sql.upper().startswith("SELECT"):
return [TextContent(type="text", text="Error: only SELECT statements are allowed.")]
def _run() -> list[dict]:
conn = _open_ro()
try:
cur = conn.execute(sql)
rows = cur.fetchmany(_QUERY_ROW_LIMIT)
return [dict(r) for r in rows]
finally:
conn.close()
try:
rows = await asyncio.get_event_loop().run_in_executor(None, _run)
return [TextContent(type="text", text=json.dumps(rows, indent=2, default=str))]
except Exception as exc:
return [TextContent(type="text", text=f"Query error: {exc}")]
async def _count_fts(args: dict) -> list[TextContent]:
match_expr = args.get("match_expr", "").strip()
if not match_expr:
return [TextContent(type="text", text="Error: match_expr is required.")]
def _run() -> int:
conn = _open_ro()
try:
cur = conn.execute(
"SELECT COUNT(*) FROM recipes_fts WHERE recipes_fts MATCH ?",
(match_expr,),
)
return cur.fetchone()[0]
finally:
conn.close()
try:
count = await asyncio.get_event_loop().run_in_executor(None, _run)
return [TextContent(type="text", text=json.dumps({"match_expr": match_expr, "count": count}))]
except Exception as exc:
return [TextContent(type="text", text=f"FTS error: {exc}")]
async def _sample_tags(args: dict) -> list[TextContent]:
prefix = args.get("prefix", "")
limit = min(int(args.get("limit", 50)), _QUERY_ROW_LIMIT)
def _run() -> list[dict]:
conn = _open_ro()
try:
# Split inferred_tags (comma or space separated) and count each tag
sql = """
WITH tag_rows AS (
SELECT trim(value) AS tag
FROM recipes, json_each('["' || replace(replace(inferred_tags, ', ', '","'), ',', '","') || '"]')
WHERE inferred_tags IS NOT NULL AND inferred_tags != ''
)
SELECT tag, COUNT(*) AS frequency
FROM tag_rows
WHERE tag LIKE ? AND tag != ''
GROUP BY tag
ORDER BY frequency DESC
LIMIT ?
"""
pattern = f"{prefix}%" if prefix else "%"
cur = conn.execute(sql, (pattern, limit))
return [{"tag": r["tag"], "frequency": r["frequency"]} for r in cur.fetchall()]
finally:
conn.close()
try:
tags = await asyncio.get_event_loop().run_in_executor(None, _run)
return [TextContent(type="text", text=json.dumps({"prefix": prefix, "tags": tags}, indent=2))]
except Exception as exc:
return [TextContent(type="text", text=f"Tag query error: {exc}")]
async def _browse_preview(args: dict) -> list[TextContent]:
domain = args.get("domain", "")
category = args.get("category", "")
subcategory = args.get("subcategory", "")
page_size = min(int(args.get("page_size", 10)), 50)
params: dict = {"page": 1, "page_size": page_size}
if subcategory:
params["subcategory"] = subcategory
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
try:
resp = await client.get(
f"{_API_URL}/api/v1/recipes/browse/{domain}/{category}",
params=params,
)
resp.raise_for_status()
except Exception as exc:
return [TextContent(type="text", text=f"Browse error: {exc}")]
data = resp.json()
summary = {
"domain": domain,
"category": category,
"subcategory": subcategory or None,
"total": data.get("total", 0),
"page_size": page_size,
"titles": [r.get("title", "") for r in data.get("recipes", [])],
}
return [TextContent(type="text", text=json.dumps(summary, indent=2))]
async def _main() -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
if __name__ == "__main__":
asyncio.run(_main())

View file

@ -1,13 +1,14 @@
"""LLM-driven recipe generator for Levels 3 and 4."""
from __future__ import annotations
import asyncio
import logging
import os
import re
from contextlib import nullcontext
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, AsyncGenerator
from openai import OpenAI
from openai import AsyncOpenAI, OpenAI
if TYPE_CHECKING:
from app.db.store import Store
@ -149,8 +150,8 @@ class LLMRecipeGenerator:
return "\n".join(lines)
_SERVICE_TYPE = "vllm"
_MODEL_CANDIDATES = ["Qwen2.5-3B-Instruct", "Phi-4-mini-instruct"]
_SERVICE_TYPE = "cf-text"
_MODEL_CANDIDATES = ["granite-4.1-8b", "deepseek-r1-1.5b"]
_TTL_S = 300.0
_CALLER = "kiwi-recipe"
@ -182,7 +183,12 @@ class LLMRecipeGenerator:
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
calls the OpenAI-compatible API directly against the allocated service URL.
Allocation failure falls through to LLMRouter rather than silently returning "".
Falls back to LLMRouter when:
- Allocation succeeded but the service is cold (warm=False) avoids
making the user wait for model load; LLMRouter uses Ollama which is
already running.
- Allocation succeeded but the connection to the service URL fails the
agent may have registered the service but failed to start it.
Without CF_ORCH_URL: uses LLMRouter directly.
"""
ctx = self._get_llm_context()
@ -208,6 +214,15 @@ class LLMRecipeGenerator:
try:
if alloc is not None:
# Skip cold services — model not yet loaded means the user would
# wait 60120 s for model load before any response. Use LLMRouter
# (Ollama) instead, which is already warm on the host.
if not alloc.warm:
logger.info(
"cf-orch vllm allocated but cold (warm=False) — releasing and falling back to LLMRouter"
)
raise RuntimeError("vllm cold")
base_url = alloc.url.rstrip("/") + "/v1"
client = OpenAI(base_url=base_url, api_key="any")
model = alloc.model or "__auto__"
@ -223,6 +238,20 @@ class LLMRecipeGenerator:
return LLMRouter().complete(prompt)
except Exception as exc:
logger.error("LLM call failed: %s", exc)
# When cf-orch gave us an allocation but the service is unreachable
# (cold skip, connection refused, or other error), fall back to
# LLMRouter rather than silently returning empty.
# Skip "vllm" in the fallback order — that backend also routes through
# cf-orch, which would trigger a second (wasted) cold allocation.
if alloc is not None:
logger.info("Falling back to LLMRouter after vllm failure")
try:
from circuitforge_core.llm.router import LLMRouter
router = LLMRouter()
_order = [b for b in (router.config.get("fallback_order") or []) if b != "vllm"]
return router.complete(prompt, fallback_order=_order or None)
except Exception as fallback_exc:
logger.error("LLMRouter fallback also failed: %s", fallback_exc)
return ""
finally:
if ctx is not None:
@ -359,3 +388,91 @@ class LLMRecipeGenerator:
suggestions=[suggestion],
element_gaps=gaps,
)
async def stream_generate(
self,
req: RecipeRequest,
profiles: list,
gaps: list[str],
) -> AsyncGenerator[str, None]:
"""Stream LLM tokens for L3/L4. Yields raw text chunks as they arrive.
Tries cf-orch warm vllm first; falls back to Ollama via AsyncOpenAI.
When neither is reachable, falls back to blocking _call_llm and yields
the complete response as a single chunk so the caller always gets output.
"""
if req.level == 4:
prompt = self.build_level4_prompt(req)
else:
prompt = self.build_level3_prompt(req, profiles, gaps)
# Phase 1: try cf-orch warm vllm (sync allocation, wrapped in thread)
alloc_info = await asyncio.to_thread(self._try_alloc_for_stream)
if alloc_info is not None:
alloc, ctx = alloc_info
try:
async for token in self._stream_openai_compat(
alloc.url.rstrip("/") + "/v1", "any", alloc.model or "__auto__", prompt
):
yield token
return
except Exception as exc:
logger.debug("cf-orch stream failed, falling back to Ollama: %s", exc)
finally:
await asyncio.to_thread(lambda: _safe_exit(ctx))
# Phase 2: Ollama streaming via OpenAI-compat API
from circuitforge_core.llm.router import LLMRouter
router = LLMRouter()
ollama = router.config.get("backends", {}).get("ollama")
if ollama and ollama.get("enabled", True):
base_url = ollama["base_url"]
model = ollama.get("model", "llama3")
try:
async for token in self._stream_openai_compat(base_url, "any", model, prompt):
yield token
return
except Exception as exc:
logger.warning("Ollama streaming failed, falling back to blocking: %s", exc)
# Phase 3: blocking fallback — yields full response at once
result = await asyncio.to_thread(self._call_llm, prompt)
if result:
yield result
def _try_alloc_for_stream(self):
"""Attempt cf-orch allocation synchronously; return (alloc, ctx) or None."""
ctx = self._get_llm_context()
try:
alloc = ctx.__enter__()
if alloc is not None and alloc.warm:
return alloc, ctx
# Not warm — release and signal fallback
_safe_exit(ctx)
except Exception as exc:
logger.debug("cf-orch alloc for stream failed: %s", exc)
return None
@staticmethod
async def _stream_openai_compat(
base_url: str, api_key: str, model: str, prompt: str
) -> AsyncGenerator[str, None]:
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
if model == "__auto__":
models = await client.models.list()
model = models.data[0].id
stream = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
stream=True,
)
async for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
def _safe_exit(ctx) -> None:
try:
ctx.__exit__(None, None, None)
except Exception:
pass

View file

@ -18,6 +18,10 @@ server {
proxy_set_header X-CF-Session $http_x_cf_session;
# Allow image uploads (barcode/receipt photos from phone cameras).
client_max_body_size 20m;
# LLM inference (recipe suggestions, expiry fallback) can take 60-120s.
# Default proxy_read_timeout is 60s which causes 504s on full recipe generation.
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
# Direct-port LAN access (localhost:8515): when VITE_API_BASE='/kiwi', the frontend
@ -34,6 +38,8 @@ server {
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-CF-Session $http_x_cf_session;
client_max_body_size 20m;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
}
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),

View file

@ -6,6 +6,7 @@
v-for="domain in domains"
:key="domain.id"
:class="['btn', activeDomain === domain.id ? 'btn-primary' : 'btn-secondary']"
:aria-pressed="activeDomain === domain.id"
@click="selectDomain(domain.id)"
>
{{ domain.label }}
@ -24,6 +25,7 @@
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
<button
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
:aria-pressed="activeCategory === '_all'"
@click="selectCategory('_all')"
>
All
@ -32,6 +34,7 @@
v-for="cat in categories"
:key="cat.category"
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === cat.category }]"
:aria-pressed="activeCategory === cat.category"
@click="selectCategory(cat.category)"
>
{{ cat.category }}
@ -57,6 +60,7 @@
<template v-else>
<button
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
:aria-pressed="activeSubcategory === null"
@click="selectSubcategory(null)"
>
All {{ activeCategory }}
@ -65,6 +69,7 @@
v-for="sub in subcategories"
:key="sub.subcategory"
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
:aria-pressed="activeSubcategory === sub.subcategory"
@click="selectSubcategory(sub.subcategory)"
>
{{ sub.subcategory }}
@ -79,6 +84,25 @@
</template>
</div>
<!-- Browse breadcrumb shows current position in domain > category > subcategory hierarchy -->
<nav v-if="activeDomain && activeCategory" class="browse-breadcrumb" aria-label="Browse location">
<button
class="crumb-btn"
@click="selectDomain(activeDomain)"
:aria-current="!activeCategory ? 'page' : undefined"
>{{ domains.find(d => d.id === activeDomain)?.label ?? activeDomain }}</button>
<span class="crumb-sep" aria-hidden="true"></span>
<button
class="crumb-btn"
@click="selectCategory(activeCategory)"
:aria-current="!activeSubcategory ? 'page' : undefined"
>{{ activeCategory === '_all' ? 'All' : activeCategory }}</button>
<template v-if="activeSubcategory">
<span class="crumb-sep" aria-hidden="true"></span>
<span class="crumb-current" aria-current="page">{{ activeSubcategory }}</span>
</template>
</nav>
<!-- Recipe grid -->
<template v-if="activeCategory">
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes</div>
@ -105,21 +129,25 @@
<div class="sort-btns flex gap-xs">
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'default' }]"
:aria-pressed="sortOrder === 'default'"
@click="setSort('default')"
title="Corpus order"
>Default</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha' }]"
:aria-pressed="sortOrder === 'alpha'"
@click="setSort('alpha')"
title="Alphabetical A→Z"
>AZ</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha_desc' }]"
:aria-pressed="sortOrder === 'alpha_desc'"
@click="setSort('alpha_desc')"
title="Alphabetical Z→A"
>ZA</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'match' }]"
:aria-pressed="sortOrder === 'match'"
:disabled="pantryCount === 0"
@click="setSort('match')"
:title="pantryCount > 0 ? 'Sort by pantry match %' : 'Add items to pantry to sort by match'"
@ -128,7 +156,11 @@
</div>
<div class="results-header flex-between mb-sm">
<span class="text-sm text-secondary">
<span
class="text-sm text-secondary"
aria-live="polite"
aria-atomic="true"
>
{{ total }} recipes
<span v-if="pantryCount > 0"> pantry match shown</span>
<span v-if="requiredIngredient.trim()"> must include "{{ requiredIngredient.trim() }}"</span>
@ -137,12 +169,14 @@
<button
class="btn btn-secondary btn-xs"
:disabled="page <= 1"
aria-label="Previous page"
@click="changePage(page - 1)"
> Prev</button>
<span class="text-sm text-secondary page-indicator">{{ page }} / {{ totalPages }}</span>
<span class="text-sm text-secondary page-indicator" aria-live="polite">{{ page }} / {{ totalPages }}</span>
<button
class="btn btn-secondary btn-xs"
:disabled="page >= totalPages"
aria-label="Next page"
@click="changePage(page + 1)"
>Next </button>
</div>
@ -854,4 +888,40 @@ async function submitTag() {
font-size: 0.875rem;
margin-left: 0.5rem;
}
/* ── Browse breadcrumb ───────────────────────────────────────────────────── */
.browse-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-xs, 0.78rem);
color: var(--color-text-secondary);
}
.crumb-btn {
background: none;
border: none;
padding: 2px 4px;
cursor: pointer;
color: var(--color-primary);
font-size: inherit;
border-radius: var(--radius-sm);
}
.crumb-btn:hover {
text-decoration: underline;
}
.crumb-sep {
opacity: 0.5;
padding: 0 2px;
}
.crumb-current {
padding: 2px 4px;
color: var(--color-text);
font-weight: 500;
}
</style>

View file

@ -95,6 +95,14 @@
<div class="card mb-controls">
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
<!-- Active-filter summary bar -->
<div v-if="activeFilterCount > 0" class="active-filter-bar" role="status">
<span class="text-sm">{{ activeFilterCount }} filter{{ activeFilterCount !== 1 ? 's' : '' }} active</span>
<button class="btn btn-xs btn-secondary" @click="clearAllFindFilters" aria-label="Clear all active filters">
Clear all
</button>
</div>
<!-- Level Selector -->
<div class="form-group">
<label class="form-label">How far should we stretch?</label>
@ -139,9 +147,8 @@
Tap "Find recipes" again to apply.
</p>
<!-- Time Budget selector (kiwi#52) -->
<!-- Shows when time_first_layout != 'normal' (auto or time_first) -->
<div v-if="settingsStore.timeFirstLayout !== 'normal'" class="form-group time-bucket-group">
<!-- Time Budget selector always visible; closes #131 -->
<div class="form-group time-bucket-group">
<!-- Hands-on / active time row -->
<div class="time-row">
<span class="time-row-label">Hands-on time</span>
@ -155,6 +162,12 @@
:aria-pressed="recipesStore.maxActiveMin === bucket.value"
:title="'Max ' + bucket.label + ' of active cooking'"
>{{ bucket.label }}</button>
<button
v-if="recipesStore.maxActiveMin !== null"
class="btn btn-sm btn-secondary time-bucket-clear"
@click="recipesStore.maxActiveMin = null"
aria-label="Clear hands-on time limit"
>No limit</button>
</div>
</div>
@ -171,6 +184,12 @@
:aria-pressed="recipesStore.maxTotalMin === bucket.value"
:title="'Max ' + bucket.label + ' start to finish'"
>{{ bucket.label }}</button>
<button
v-if="recipesStore.maxTotalMin !== null"
class="btn btn-sm btn-secondary time-bucket-clear"
@click="recipesStore.maxTotalMin = null"
aria-label="Clear total time limit"
>No limit</button>
</div>
</div>
@ -246,9 +265,9 @@
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
</div>
<!-- Not Today temporary per-session ingredient exclusions -->
<!-- Not Today ingredient exclusions, persisted to localStorage -->
<div class="form-group">
<label class="form-label">Not today <span class="text-muted text-xs">(skip these ingredients this session)</span></label>
<label class="form-label">Not today <span class="text-muted text-xs">(saved between visits)</span></label>
<div v-if="recipesStore.excludeIngredients.length > 0" class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.excludeIngredients"
@ -968,6 +987,42 @@ const advancedActive = computed(() =>
!!recipesStore.styleId
)
const activeFilterCount = computed(() => {
let n = 0
if (recipesStore.constraints.length > 0) n++
if (recipesStore.allergies.length > 0) n++
if (recipesStore.excludeIngredients.length > 0) n++
if (recipesStore.shoppingMode) n++
if (recipesStore.pantryMatchOnly) n++
if (recipesStore.hardDayMode) n++
if (recipesStore.maxActiveMin !== null) n++
if (recipesStore.maxTotalMin !== null) n++
if (recipesStore.maxMissing !== null) n++
if (recipesStore.styleId !== null) n++
if (recipesStore.category !== null) n++
n += Object.values(recipesStore.nutritionFilters).filter((v) => v !== null).length
return n
})
function clearAllFindFilters() {
recipesStore.clearConstraints()
recipesStore.clearAllergies()
recipesStore.clearExcludeIngredients()
recipesStore.shoppingMode = false
recipesStore.pantryMatchOnly = false
recipesStore.hardDayMode = false
recipesStore.maxActiveMin = null
recipesStore.maxTotalMin = null
recipesStore.maxMissing = null
recipesStore.styleId = null
recipesStore.category = null
recipesStore.nutritionFilters = { max_calories: null, max_sugar_g: null, max_carbs_g: null, max_sodium_mg: null }
constraintInput.value = ''
allergyInput.value = ''
excludeIngredientInput.value = ''
categoryInput.value = ''
}
// #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
@ -1122,41 +1177,35 @@ async function streamRecipe(level: 3 | 4, wildcardConfirmed = false) {
streamChunks.value = ''
streamError.value = null
let tokenData: StreamTokenResponse
// Try cf-orch warm vllm path first (returns a direct stream URL)
let tokenData: StreamTokenResponse | null = null
try {
tokenData = await recipesAPI.getRecipeStreamToken({ level, wildcard_confirmed: wildcardConfirmed })
} catch (err: unknown) {
isStreaming.value = false
streamError.value = err instanceof Error ? err.message : 'Failed to start stream'
return
}
} catch { /* cf-orch unavailable — fall through to native SSE */ }
if (tokenData) {
const url = `${tokenData.stream_url}?token=${encodeURIComponent(tokenData.token)}`
const es = new EventSource(url)
es.onmessage = (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
if (data.done) {
es.close()
isStreaming.value = false
} else if (data.error) {
es.close()
isStreaming.value = false
streamError.value = data.error
} else if (data.chunk) {
streamChunks.value += data.chunk
}
} catch {
// ignore malformed events
if (data.done) { es.close(); isStreaming.value = false }
else if (data.error) { es.close(); isStreaming.value = false; streamError.value = data.error }
else if (data.chunk) { streamChunks.value += data.chunk }
} catch { /* ignore malformed events */ }
}
es.onerror = () => { es.close(); isStreaming.value = false; streamError.value = 'Stream connection lost' }
return
}
es.onerror = () => {
es.close()
isStreaming.value = false
streamError.value = 'Stream connection lost'
}
// Native SSE fallback: Kiwi backend streams directly from Ollama
await recipesStore.streamSuggest(
pantryItems.value,
secondaryPantryItems.value,
(chunk) => { streamChunks.value += chunk },
() => { isStreaming.value = false },
(err) => { isStreaming.value = false; streamError.value = err },
)
}
// Suggest handler
@ -1393,6 +1442,18 @@ watch(
}
/* Filter bar */
.active-filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
}
.filter-bar {
display: flex;
flex-direction: column;
@ -1588,6 +1649,15 @@ details[open] .collapsible-summary::before {
border-color: var(--color-primary, #1a6b4a);
}
.time-bucket-clear {
font-style: italic;
opacity: 0.7;
}
.time-bucket-clear:hover {
opacity: 1;
}
@media (max-width: 480px) {
.time-row {
flex-direction: column;

View file

@ -2,6 +2,7 @@
<div class="settings-view">
<div class="card">
<h2 class="section-title text-xl mb-md">Settings</h2>
<p class="text-xs text-muted mb-md">Changes save automatically.</p>
<!-- Cooking Equipment -->
<section>
@ -19,7 +20,7 @@
class="tag-chip status-badge status-info"
>
{{ item }}
<button class="chip-remove" @click="removeEquipment(item)" aria-label="Remove">×</button>
<button class="chip-remove" @click="removeEquipment(item)" :aria-label="'Remove equipment: ' + item">×</button>
</span>
</div>
@ -50,18 +51,6 @@
</div>
</div>
<!-- Save button -->
<div class="flex-start gap-sm">
<button
class="btn btn-primary"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save Settings</span>
</button>
</div>
</section>
<!-- Sensory Preferences -->
@ -134,17 +123,6 @@
</p>
</div>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.saveSensory()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved">Saved!</span>
<span v-else>Save sensory preferences</span>
</button>
</div>
</section>
<!-- Units -->
@ -169,17 +147,6 @@
Imperial (oz, cups, °F)
</button>
</div>
<div class="flex-start gap-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section>
<!-- Shopping Locale -->
@ -220,17 +187,6 @@
<option value="br">Brazil (BRL R$)</option>
</optgroup>
</select>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section>
<!-- Time-First Layout -->
@ -258,17 +214,6 @@
</span>
</label>
</div>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.save()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved"> Saved!</span>
<span v-else>Save</span>
</button>
</div>
</section>
<!-- Data Sharing (cloud only) -->
@ -393,6 +338,12 @@
</template>
</div>
</div>
<Transition name="autosave-fade">
<div v-if="settingsStore.saved" class="autosave-toast" role="status" aria-live="polite">
Saved
</div>
</Transition>
</template>
<script setup lang="ts">
@ -871,4 +822,32 @@ function getNoiseClass(_value: NoiseLevel, idx: number): string {
border-color: var(--color-border, #e0e0e0);
color: var(--color-text-secondary, #888);
}
/* ── Autosave toast ──────────────────────────────────────────────────────── */
.autosave-toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e0e0e0);
border-radius: var(--radius-md, 0.5rem);
padding: 0.4rem 0.9rem;
font-size: var(--font-size-sm);
color: var(--color-success, #4a8c40);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
z-index: 500;
pointer-events: none;
}
.autosave-fade-enter-active,
.autosave-fade-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.autosave-fade-enter-from,
.autosave-fade-leave-to {
opacity: 0;
transform: translateY(0.5rem);
}
</style>

View file

@ -737,6 +737,54 @@ export const recipesAPI = {
})
return response.data
},
/** Stream a recipe via native SSE (Ollama fallback). Calls callbacks as tokens arrive. */
async suggestRecipeStream(
req: RecipeRequest,
onChunk: (chunk: string) => void,
onDone: () => void,
onError: (err: string) => void,
): Promise<void> {
const baseUrl = (api.defaults.baseURL ?? '') as string
let response: Response
try {
response = await fetch(`${baseUrl}/recipes/suggest?stream=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
} catch (err: unknown) {
onError(err instanceof Error ? err.message : 'Network error')
return
}
if (!response.ok) {
onError(`HTTP ${response.status}`)
return
}
const reader = response.body?.getReader()
if (!reader) { onError('No response body'); return }
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) { onDone(); break }
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() ?? ''
for (const part of parts) {
if (!part.startsWith('data: ')) continue
try {
const data = JSON.parse(part.slice(6))
if (data.done) { onDone(); return }
else if (data.error) { onError(data.error); return }
else if (data.chunk) { onChunk(data.chunk) }
} catch { /* ignore malformed events */ }
}
}
},
}
// ========== Settings API ==========

View file

@ -379,6 +379,17 @@ export const useRecipesStore = defineStore('recipes', () => {
wildcardConfirmed.value = false
}
async function streamSuggest(
pantryItems: string[],
secondaryPantryItems: Record<string, string>,
onChunk: (chunk: string) => void,
onDone: () => void,
onError: (err: string) => void,
): Promise<void> {
const req = _buildRequest(pantryItems, secondaryPantryItems)
await recipesAPI.suggestRecipeStream(req, onChunk, onDone, onError)
}
return {
result,
loading,
@ -416,6 +427,7 @@ export const useRecipesStore = defineStore('recipes', () => {
missingIngredientMode,
builderFilterMode,
suggest,
streamSuggest,
loadMore,
dismiss,
undismiss,

View file

@ -1,11 +1,5 @@
/**
* Settings Store
*
* Manages user settings (cooking equipment, preferences) using Pinia.
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, watch, nextTick } from 'vue'
import { settingsAPI } from '../services/api'
import type { UnitSystem } from '../utils/units'
import type { SensoryPreferences } from '../services/api'
@ -13,8 +7,12 @@ import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
function debounce(fn: () => void, ms: number): () => void {
let t: ReturnType<typeof setTimeout>
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
}
export const useSettingsStore = defineStore('settings', () => {
// State
const cookingEquipment = ref<string[]>([])
const unitSystem = ref<UnitSystem>('metric')
const shoppingLocale = ref<string>('us')
@ -23,7 +21,40 @@ export const useSettingsStore = defineStore('settings', () => {
const loading = ref(false)
const saved = ref(false)
// Actions
// Prevents autosave watchers from firing during initial load hydration.
// Set to true after nextTick() at the end of load() — by that point all
// watcher jobs queued by the hydration assignments have already flushed.
let _hydrated = false
function _flash() {
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
async function _saveKey(key: string, value: string): Promise<void> {
if (!_hydrated) return
try {
await settingsAPI.setSetting(key, value)
_flash()
} catch (err: unknown) {
console.error('Autosave failed for key:', key, err)
}
}
const _autosave = {
equipment: debounce(() => _saveKey('cooking_equipment', JSON.stringify(cookingEquipment.value)), 600),
unit: debounce(() => _saveKey('unit_system', unitSystem.value), 600),
locale: debounce(() => _saveKey('shopping_locale', shoppingLocale.value), 600),
sensory: debounce(() => _saveKey('sensory_preferences', JSON.stringify(sensoryPreferences.value)), 600),
layout: debounce(() => _saveKey('time_first_layout', timeFirstLayout.value), 600),
}
watch(cookingEquipment, _autosave.equipment, { deep: true })
watch(unitSystem, _autosave.unit)
watch(shoppingLocale, _autosave.locale)
watch(sensoryPreferences, _autosave.sensory, { deep: true })
watch(timeFirstLayout, _autosave.layout)
async function load() {
loading.value = true
try {
@ -58,8 +89,15 @@ export const useSettingsStore = defineStore('settings', () => {
} finally {
loading.value = false
}
// Yield past the watcher flush triggered by hydration assignments above.
// After nextTick, any pending watcher jobs from this load() have already
// run (and been ignored by _hydrated guard), so user-driven changes from
// here forward will correctly trigger autosave.
await nextTick()
_hydrated = true
}
// Kept for explicit full-save scenarios (e.g. fallback, tests).
async function save() {
loading.value = true
try {
@ -70,10 +108,7 @@ export const useSettingsStore = defineStore('settings', () => {
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
])
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
_flash()
} catch (err: unknown) {
console.error('Failed to save settings:', err)
} finally {
@ -81,24 +116,17 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
// Kept for backward compat; autosave handles sensory changes now.
async function saveSensory() {
loading.value = true
try {
await settingsAPI.setSetting(
'sensory_preferences',
JSON.stringify(sensoryPreferences.value),
)
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
await settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value))
_flash()
} catch (err: unknown) {
console.error('Failed to save sensory preferences:', err)
} finally {
loading.value = false
}
}
return {
// State
cookingEquipment,
unitSystem,
shoppingLocale,
@ -106,8 +134,6 @@ export const useSettingsStore = defineStore('settings', () => {
timeFirstLayout,
loading,
saved,
// Actions
load,
save,
saveSensory,

View file

@ -14,8 +14,8 @@ OVERRIDE_FLAG=""
[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml"
usage() {
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test"
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}"
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test|update"
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build|cloud-update}"
echo ""
echo "Dev:"
echo " start Build (if needed) and start all services"
@ -26,6 +26,7 @@ usage() {
echo " open Open web UI in browser"
echo " build Rebuild Docker images without cache"
echo " test Run pytest test suite"
echo " update git pull + rebuild + restart dev stack"
echo ""
echo "Cloud (menagerie.circuitforge.tech/kiwi):"
echo " cloud-start Build cloud images and start kiwi-cloud project"
@ -34,6 +35,7 @@ usage() {
echo " cloud-status Show cloud containers"
echo " cloud-logs Follow cloud logs [api|web — defaults to all]"
echo " cloud-build Rebuild cloud images without cache"
echo " cloud-update git pull + rebuild + restart cloud stack"
exit 1
}
@ -68,6 +70,11 @@ case "$cmd" in
build)
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG build --no-cache
;;
update)
git pull
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
echo "Kiwi updated and restarted → http://localhost:${WEB_PORT}"
;;
test)
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG run --rm api \
conda run -n job-seeker pytest tests/ -v
@ -95,6 +102,11 @@ case "$cmd" in
cloud-build)
docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" build --no-cache
;;
cloud-update)
git pull
docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" up -d --build
echo "Kiwi cloud updated and restarted → https://menagerie.circuitforge.tech/kiwi"
;;
*)
usage