Compare commits
9 commits
e62d69d099
...
3f4b756fc6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f4b756fc6 | |||
| 973c76a4c8 | |||
| 92fab94ae0 | |||
| 30f5620fd5 | |||
| 0ef57618bf | |||
| 8c765b7da2 | |||
| e57f46f4b6 | |||
| 04dbdddbad | |||
| e83bb0415a |
13 changed files with 840 additions and 148 deletions
|
|
@ -478,7 +478,8 @@ async def scan_barcode_image(
|
||||||
from app.services.openfoodfacts import OpenFoodFactsService
|
from app.services.openfoodfacts import OpenFoodFactsService
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
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:
|
if not barcodes:
|
||||||
return BarcodeScanResponse(
|
return BarcodeScanResponse(
|
||||||
success=False, barcodes_found=0, results=[],
|
success=False, barcodes_found=0, results=[],
|
||||||
|
|
@ -500,9 +501,10 @@ async def scan_barcode_image(
|
||||||
product_info = await off.lookup_product(code)
|
product_info = await off.lookup_product(code)
|
||||||
product_source = "openfoodfacts"
|
product_source = "openfoodfacts"
|
||||||
|
|
||||||
|
db_product = None
|
||||||
inventory_item = None
|
inventory_item = None
|
||||||
if product_info and auto_add_to_inventory:
|
if product_info:
|
||||||
product, _ = await asyncio.to_thread(
|
db_product, _ = await asyncio.to_thread(
|
||||||
store.get_or_create_product,
|
store.get_or_create_product,
|
||||||
product_info.get("name", code),
|
product_info.get("name", code),
|
||||||
code,
|
code,
|
||||||
|
|
@ -512,6 +514,7 @@ async def scan_barcode_image(
|
||||||
source=product_source,
|
source=product_source,
|
||||||
source_data=product_info,
|
source_data=product_info,
|
||||||
)
|
)
|
||||||
|
if auto_add_to_inventory:
|
||||||
exp = predictor.predict_expiration(
|
exp = predictor.predict_expiration(
|
||||||
product_info.get("category", ""),
|
product_info.get("category", ""),
|
||||||
location,
|
location,
|
||||||
|
|
@ -523,18 +526,18 @@ async def scan_barcode_image(
|
||||||
resolved_unit = product_info.get("pack_unit") or "count"
|
resolved_unit = product_info.get("pack_unit") or "count"
|
||||||
inventory_item = await asyncio.to_thread(
|
inventory_item = await asyncio.to_thread(
|
||||||
store.add_inventory_item,
|
store.add_inventory_item,
|
||||||
product["id"], location,
|
db_product["id"], location,
|
||||||
quantity=resolved_qty,
|
quantity=resolved_qty,
|
||||||
unit=resolved_unit,
|
unit=resolved_unit,
|
||||||
expiration_date=str(exp) if exp else None,
|
expiration_date=str(exp) if exp else None,
|
||||||
source="barcode_scan",
|
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
|
needs_capture = not product_found and has_visual_capture
|
||||||
results.append({
|
results.append({
|
||||||
"barcode": code,
|
"barcode": code,
|
||||||
"barcode_type": bc.get("type", "unknown"),
|
"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,
|
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
||||||
"added_to_inventory": inventory_item is not None,
|
"added_to_inventory": inventory_item is not None,
|
||||||
"needs_manual_entry": not product_found and not needs_capture,
|
"needs_manual_entry": not product_found and not needs_capture,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
import json as _json_mod
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
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()
|
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):
|
async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
|
||||||
"""Queue an async recipe_llm job and return 202 with job_id.
|
"""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(
|
async def suggest_recipes(
|
||||||
req: RecipeRequest,
|
req: RecipeRequest,
|
||||||
async_mode: bool = Query(default=False, alias="async"),
|
async_mode: bool = Query(default=False, alias="async"),
|
||||||
|
stream: bool = Query(default=False),
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
):
|
):
|
||||||
|
|
@ -179,6 +215,13 @@ async def suggest_recipes(
|
||||||
req = req.model_copy(update={"level": 2})
|
req = req.model_copy(update={"level": 2})
|
||||||
orch_fallback = True
|
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:
|
if req.level in (3, 4) and async_mode:
|
||||||
return await _enqueue_recipe_job(session, req)
|
return await _enqueue_recipe_job(session, req)
|
||||||
|
|
||||||
|
|
|
||||||
0
app/mcp/__init__.py
Normal file
0
app/mcp/__init__.py
Normal file
306
app/mcp/server.py
Normal file
306
app/mcp/server.py
Normal 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())
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
"""LLM-driven recipe generator for Levels 3 and 4."""
|
"""LLM-driven recipe generator for Levels 3 and 4."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from contextlib import nullcontext
|
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:
|
if TYPE_CHECKING:
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
@ -149,8 +150,8 @@ class LLMRecipeGenerator:
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
_SERVICE_TYPE = "vllm"
|
_SERVICE_TYPE = "cf-text"
|
||||||
_MODEL_CANDIDATES = ["Qwen2.5-3B-Instruct", "Phi-4-mini-instruct"]
|
_MODEL_CANDIDATES = ["granite-4.1-8b", "deepseek-r1-1.5b"]
|
||||||
_TTL_S = 300.0
|
_TTL_S = 300.0
|
||||||
_CALLER = "kiwi-recipe"
|
_CALLER = "kiwi-recipe"
|
||||||
|
|
||||||
|
|
@ -182,7 +183,12 @@ class LLMRecipeGenerator:
|
||||||
|
|
||||||
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
||||||
calls the OpenAI-compatible API directly against the allocated service URL.
|
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.
|
Without CF_ORCH_URL: uses LLMRouter directly.
|
||||||
"""
|
"""
|
||||||
ctx = self._get_llm_context()
|
ctx = self._get_llm_context()
|
||||||
|
|
@ -208,6 +214,15 @@ class LLMRecipeGenerator:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if alloc is not None:
|
if alloc is not None:
|
||||||
|
# Skip cold services — model not yet loaded means the user would
|
||||||
|
# wait 60–120 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"
|
base_url = alloc.url.rstrip("/") + "/v1"
|
||||||
client = OpenAI(base_url=base_url, api_key="any")
|
client = OpenAI(base_url=base_url, api_key="any")
|
||||||
model = alloc.model or "__auto__"
|
model = alloc.model or "__auto__"
|
||||||
|
|
@ -223,6 +238,20 @@ class LLMRecipeGenerator:
|
||||||
return LLMRouter().complete(prompt)
|
return LLMRouter().complete(prompt)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("LLM call failed: %s", 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 ""
|
return ""
|
||||||
finally:
|
finally:
|
||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
|
|
@ -359,3 +388,91 @@ class LLMRecipeGenerator:
|
||||||
suggestions=[suggestion],
|
suggestions=[suggestion],
|
||||||
element_gaps=gaps,
|
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
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ server {
|
||||||
proxy_set_header X-CF-Session $http_x_cf_session;
|
proxy_set_header X-CF-Session $http_x_cf_session;
|
||||||
# Allow image uploads (barcode/receipt photos from phone cameras).
|
# Allow image uploads (barcode/receipt photos from phone cameras).
|
||||||
client_max_body_size 20m;
|
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
|
# 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-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
proxy_set_header X-CF-Session $http_x_cf_session;
|
proxy_set_header X-CF-Session $http_x_cf_session;
|
||||||
client_max_body_size 20m;
|
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),
|
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
v-for="domain in domains"
|
v-for="domain in domains"
|
||||||
:key="domain.id"
|
:key="domain.id"
|
||||||
:class="['btn', activeDomain === domain.id ? 'btn-primary' : 'btn-secondary']"
|
:class="['btn', activeDomain === domain.id ? 'btn-primary' : 'btn-secondary']"
|
||||||
|
:aria-pressed="activeDomain === domain.id"
|
||||||
@click="selectDomain(domain.id)"
|
@click="selectDomain(domain.id)"
|
||||||
>
|
>
|
||||||
{{ domain.label }}
|
{{ domain.label }}
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
|
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
|
||||||
<button
|
<button
|
||||||
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
|
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
|
||||||
|
:aria-pressed="activeCategory === '_all'"
|
||||||
@click="selectCategory('_all')"
|
@click="selectCategory('_all')"
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
|
|
@ -32,6 +34,7 @@
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat.category"
|
:key="cat.category"
|
||||||
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === cat.category }]"
|
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === cat.category }]"
|
||||||
|
:aria-pressed="activeCategory === cat.category"
|
||||||
@click="selectCategory(cat.category)"
|
@click="selectCategory(cat.category)"
|
||||||
>
|
>
|
||||||
{{ cat.category }}
|
{{ cat.category }}
|
||||||
|
|
@ -57,6 +60,7 @@
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
|
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
|
||||||
|
:aria-pressed="activeSubcategory === null"
|
||||||
@click="selectSubcategory(null)"
|
@click="selectSubcategory(null)"
|
||||||
>
|
>
|
||||||
All {{ activeCategory }}
|
All {{ activeCategory }}
|
||||||
|
|
@ -65,6 +69,7 @@
|
||||||
v-for="sub in subcategories"
|
v-for="sub in subcategories"
|
||||||
:key="sub.subcategory"
|
:key="sub.subcategory"
|
||||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
|
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
|
||||||
|
:aria-pressed="activeSubcategory === sub.subcategory"
|
||||||
@click="selectSubcategory(sub.subcategory)"
|
@click="selectSubcategory(sub.subcategory)"
|
||||||
>
|
>
|
||||||
{{ sub.subcategory }}
|
{{ sub.subcategory }}
|
||||||
|
|
@ -79,6 +84,25 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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 -->
|
<!-- Recipe grid -->
|
||||||
<template v-if="activeCategory">
|
<template v-if="activeCategory">
|
||||||
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
||||||
|
|
@ -105,21 +129,25 @@
|
||||||
<div class="sort-btns flex gap-xs">
|
<div class="sort-btns flex gap-xs">
|
||||||
<button
|
<button
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'default' }]"
|
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'default' }]"
|
||||||
|
:aria-pressed="sortOrder === 'default'"
|
||||||
@click="setSort('default')"
|
@click="setSort('default')"
|
||||||
title="Corpus order"
|
title="Corpus order"
|
||||||
>Default</button>
|
>Default</button>
|
||||||
<button
|
<button
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha' }]"
|
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha' }]"
|
||||||
|
:aria-pressed="sortOrder === 'alpha'"
|
||||||
@click="setSort('alpha')"
|
@click="setSort('alpha')"
|
||||||
title="Alphabetical A→Z"
|
title="Alphabetical A→Z"
|
||||||
>A→Z</button>
|
>A→Z</button>
|
||||||
<button
|
<button
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha_desc' }]"
|
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha_desc' }]"
|
||||||
|
:aria-pressed="sortOrder === 'alpha_desc'"
|
||||||
@click="setSort('alpha_desc')"
|
@click="setSort('alpha_desc')"
|
||||||
title="Alphabetical Z→A"
|
title="Alphabetical Z→A"
|
||||||
>Z→A</button>
|
>Z→A</button>
|
||||||
<button
|
<button
|
||||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'match' }]"
|
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'match' }]"
|
||||||
|
:aria-pressed="sortOrder === 'match'"
|
||||||
:disabled="pantryCount === 0"
|
:disabled="pantryCount === 0"
|
||||||
@click="setSort('match')"
|
@click="setSort('match')"
|
||||||
:title="pantryCount > 0 ? 'Sort by pantry match %' : 'Add items to pantry to sort by match'"
|
:title="pantryCount > 0 ? 'Sort by pantry match %' : 'Add items to pantry to sort by match'"
|
||||||
|
|
@ -128,7 +156,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="results-header flex-between mb-sm">
|
<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
|
{{ total }} recipes
|
||||||
<span v-if="pantryCount > 0"> — pantry match shown</span>
|
<span v-if="pantryCount > 0"> — pantry match shown</span>
|
||||||
<span v-if="requiredIngredient.trim()"> — must include "{{ requiredIngredient.trim() }}"</span>
|
<span v-if="requiredIngredient.trim()"> — must include "{{ requiredIngredient.trim() }}"</span>
|
||||||
|
|
@ -137,12 +169,14 @@
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary btn-xs"
|
class="btn btn-secondary btn-xs"
|
||||||
:disabled="page <= 1"
|
:disabled="page <= 1"
|
||||||
|
aria-label="Previous page"
|
||||||
@click="changePage(page - 1)"
|
@click="changePage(page - 1)"
|
||||||
>‹ Prev</button>
|
>‹ 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
|
<button
|
||||||
class="btn btn-secondary btn-xs"
|
class="btn btn-secondary btn-xs"
|
||||||
:disabled="page >= totalPages"
|
:disabled="page >= totalPages"
|
||||||
|
aria-label="Next page"
|
||||||
@click="changePage(page + 1)"
|
@click="changePage(page + 1)"
|
||||||
>Next ›</button>
|
>Next ›</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -854,4 +888,40 @@ async function submitTag() {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-left: 0.5rem;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,14 @@
|
||||||
<div class="card mb-controls">
|
<div class="card mb-controls">
|
||||||
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
|
<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 -->
|
<!-- Level Selector -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">How far should we stretch?</label>
|
<label class="form-label">How far should we stretch?</label>
|
||||||
|
|
@ -139,9 +147,8 @@
|
||||||
Tap "Find recipes" again to apply.
|
Tap "Find recipes" again to apply.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Time Budget selector (kiwi#52) -->
|
<!-- Time Budget selector — always visible; closes #131 -->
|
||||||
<!-- Shows when time_first_layout != 'normal' (auto or time_first) -->
|
<div class="form-group time-bucket-group">
|
||||||
<div v-if="settingsStore.timeFirstLayout !== 'normal'" class="form-group time-bucket-group">
|
|
||||||
<!-- Hands-on / active time row -->
|
<!-- Hands-on / active time row -->
|
||||||
<div class="time-row">
|
<div class="time-row">
|
||||||
<span class="time-row-label">Hands-on time</span>
|
<span class="time-row-label">Hands-on time</span>
|
||||||
|
|
@ -155,6 +162,12 @@
|
||||||
:aria-pressed="recipesStore.maxActiveMin === bucket.value"
|
:aria-pressed="recipesStore.maxActiveMin === bucket.value"
|
||||||
:title="'Max ' + bucket.label + ' of active cooking'"
|
:title="'Max ' + bucket.label + ' of active cooking'"
|
||||||
>{{ bucket.label }}</button>
|
>{{ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -171,6 +184,12 @@
|
||||||
:aria-pressed="recipesStore.maxTotalMin === bucket.value"
|
:aria-pressed="recipesStore.maxTotalMin === bucket.value"
|
||||||
:title="'Max ' + bucket.label + ' start to finish'"
|
:title="'Max ' + bucket.label + ' start to finish'"
|
||||||
>{{ bucket.label }}</button>
|
>{{ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -246,9 +265,9 @@
|
||||||
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
|
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Not Today — temporary per-session ingredient exclusions -->
|
<!-- Not Today — ingredient exclusions, persisted to localStorage -->
|
||||||
<div class="form-group">
|
<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">
|
<div v-if="recipesStore.excludeIngredients.length > 0" class="tags-wrap flex flex-wrap gap-xs mb-xs">
|
||||||
<span
|
<span
|
||||||
v-for="tag in recipesStore.excludeIngredients"
|
v-for="tag in recipesStore.excludeIngredients"
|
||||||
|
|
@ -968,6 +987,42 @@ const advancedActive = computed(() =>
|
||||||
!!recipesStore.styleId
|
!!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
|
// #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
|
||||||
|
|
@ -1122,41 +1177,35 @@ async function streamRecipe(level: 3 | 4, wildcardConfirmed = false) {
|
||||||
streamChunks.value = ''
|
streamChunks.value = ''
|
||||||
streamError.value = null
|
streamError.value = null
|
||||||
|
|
||||||
let tokenData: StreamTokenResponse
|
// Try cf-orch warm vllm path first (returns a direct stream URL)
|
||||||
|
let tokenData: StreamTokenResponse | null = null
|
||||||
try {
|
try {
|
||||||
tokenData = await recipesAPI.getRecipeStreamToken({ level, wildcard_confirmed: wildcardConfirmed })
|
tokenData = await recipesAPI.getRecipeStreamToken({ level, wildcard_confirmed: wildcardConfirmed })
|
||||||
} catch (err: unknown) {
|
} catch { /* cf-orch unavailable — fall through to native SSE */ }
|
||||||
isStreaming.value = false
|
|
||||||
streamError.value = err instanceof Error ? err.message : 'Failed to start stream'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (tokenData) {
|
||||||
const url = `${tokenData.stream_url}?token=${encodeURIComponent(tokenData.token)}`
|
const url = `${tokenData.stream_url}?token=${encodeURIComponent(tokenData.token)}`
|
||||||
const es = new EventSource(url)
|
const es = new EventSource(url)
|
||||||
|
|
||||||
es.onmessage = (e: MessageEvent) => {
|
es.onmessage = (e: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data)
|
const data = JSON.parse(e.data)
|
||||||
if (data.done) {
|
if (data.done) { es.close(); isStreaming.value = false }
|
||||||
es.close()
|
else if (data.error) { es.close(); isStreaming.value = false; streamError.value = data.error }
|
||||||
isStreaming.value = false
|
else if (data.chunk) { streamChunks.value += data.chunk }
|
||||||
} else if (data.error) {
|
} catch { /* ignore malformed events */ }
|
||||||
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 = () => {
|
// Native SSE fallback: Kiwi backend streams directly from Ollama
|
||||||
es.close()
|
await recipesStore.streamSuggest(
|
||||||
isStreaming.value = false
|
pantryItems.value,
|
||||||
streamError.value = 'Stream connection lost'
|
secondaryPantryItems.value,
|
||||||
}
|
(chunk) => { streamChunks.value += chunk },
|
||||||
|
() => { isStreaming.value = false },
|
||||||
|
(err) => { isStreaming.value = false; streamError.value = err },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggest handler
|
// Suggest handler
|
||||||
|
|
@ -1393,6 +1442,18 @@ watch(
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filter bar */
|
/* 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 {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1588,6 +1649,15 @@ details[open] .collapsible-summary::before {
|
||||||
border-color: var(--color-primary, #1a6b4a);
|
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) {
|
@media (max-width: 480px) {
|
||||||
.time-row {
|
.time-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<div class="settings-view">
|
<div class="settings-view">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="section-title text-xl mb-md">Settings</h2>
|
<h2 class="section-title text-xl mb-md">Settings</h2>
|
||||||
|
<p class="text-xs text-muted mb-md">Changes save automatically.</p>
|
||||||
|
|
||||||
<!-- Cooking Equipment -->
|
<!-- Cooking Equipment -->
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -19,7 +20,7 @@
|
||||||
class="tag-chip status-badge status-info"
|
class="tag-chip status-badge status-info"
|
||||||
>
|
>
|
||||||
{{ item }}
|
{{ 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -50,18 +51,6 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Sensory Preferences -->
|
<!-- Sensory Preferences -->
|
||||||
|
|
@ -134,17 +123,6 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Units -->
|
<!-- Units -->
|
||||||
|
|
@ -169,17 +147,6 @@
|
||||||
Imperial (oz, cups, °F)
|
Imperial (oz, cups, °F)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Shopping Locale -->
|
<!-- Shopping Locale -->
|
||||||
|
|
@ -220,17 +187,6 @@
|
||||||
<option value="br">Brazil (BRL R$)</option>
|
<option value="br">Brazil (BRL R$)</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Time-First Layout -->
|
<!-- Time-First Layout -->
|
||||||
|
|
@ -258,17 +214,6 @@
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Data Sharing (cloud only) -->
|
<!-- Data Sharing (cloud only) -->
|
||||||
|
|
@ -393,6 +338,12 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Transition name="autosave-fade">
|
||||||
|
<div v-if="settingsStore.saved" class="autosave-toast" role="status" aria-live="polite">
|
||||||
|
✓ Saved
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -871,4 +822,32 @@ function getNoiseClass(_value: NoiseLevel, idx: number): string {
|
||||||
border-color: var(--color-border, #e0e0e0);
|
border-color: var(--color-border, #e0e0e0);
|
||||||
color: var(--color-text-secondary, #888);
|
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>
|
</style>
|
||||||
|
|
@ -737,6 +737,54 @@ export const recipesAPI = {
|
||||||
})
|
})
|
||||||
return response.data
|
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 ==========
|
// ========== Settings API ==========
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,17 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
wildcardConfirmed.value = false
|
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 {
|
return {
|
||||||
result,
|
result,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -416,6 +427,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
missingIngredientMode,
|
missingIngredientMode,
|
||||||
builderFilterMode,
|
builderFilterMode,
|
||||||
suggest,
|
suggest,
|
||||||
|
streamSuggest,
|
||||||
loadMore,
|
loadMore,
|
||||||
dismiss,
|
dismiss,
|
||||||
undismiss,
|
undismiss,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
/**
|
|
||||||
* Settings Store
|
|
||||||
*
|
|
||||||
* Manages user settings (cooking equipment, preferences) using Pinia.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { settingsAPI } from '../services/api'
|
import { settingsAPI } from '../services/api'
|
||||||
import type { UnitSystem } from '../utils/units'
|
import type { UnitSystem } from '../utils/units'
|
||||||
import type { SensoryPreferences } from '../services/api'
|
import type { SensoryPreferences } from '../services/api'
|
||||||
|
|
@ -13,8 +7,12 @@ import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
|
||||||
|
|
||||||
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
|
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', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
// State
|
|
||||||
const cookingEquipment = ref<string[]>([])
|
const cookingEquipment = ref<string[]>([])
|
||||||
const unitSystem = ref<UnitSystem>('metric')
|
const unitSystem = ref<UnitSystem>('metric')
|
||||||
const shoppingLocale = ref<string>('us')
|
const shoppingLocale = ref<string>('us')
|
||||||
|
|
@ -23,7 +21,40 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saved = 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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -58,8 +89,15 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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() {
|
async function save() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -70,10 +108,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
|
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
|
||||||
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
|
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
|
||||||
])
|
])
|
||||||
saved.value = true
|
_flash()
|
||||||
setTimeout(() => {
|
|
||||||
saved.value = false
|
|
||||||
}, 2000)
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to save settings:', err)
|
console.error('Failed to save settings:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -81,24 +116,17 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept for backward compat; autosave handles sensory changes now.
|
||||||
async function saveSensory() {
|
async function saveSensory() {
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
await settingsAPI.setSetting(
|
await settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value))
|
||||||
'sensory_preferences',
|
_flash()
|
||||||
JSON.stringify(sensoryPreferences.value),
|
|
||||||
)
|
|
||||||
saved.value = true
|
|
||||||
setTimeout(() => { saved.value = false }, 2000)
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to save sensory preferences:', err)
|
console.error('Failed to save sensory preferences:', err)
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
|
||||||
cookingEquipment,
|
cookingEquipment,
|
||||||
unitSystem,
|
unitSystem,
|
||||||
shoppingLocale,
|
shoppingLocale,
|
||||||
|
|
@ -106,8 +134,6 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
timeFirstLayout,
|
timeFirstLayout,
|
||||||
loading,
|
loading,
|
||||||
saved,
|
saved,
|
||||||
|
|
||||||
// Actions
|
|
||||||
load,
|
load,
|
||||||
save,
|
save,
|
||||||
saveSensory,
|
saveSensory,
|
||||||
|
|
|
||||||
16
manage.sh
16
manage.sh
|
|
@ -14,8 +14,8 @@ OVERRIDE_FLAG=""
|
||||||
[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml"
|
[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test"
|
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}"
|
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build|cloud-update}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Dev:"
|
echo "Dev:"
|
||||||
echo " start Build (if needed) and start all services"
|
echo " start Build (if needed) and start all services"
|
||||||
|
|
@ -26,6 +26,7 @@ usage() {
|
||||||
echo " open Open web UI in browser"
|
echo " open Open web UI in browser"
|
||||||
echo " build Rebuild Docker images without cache"
|
echo " build Rebuild Docker images without cache"
|
||||||
echo " test Run pytest test suite"
|
echo " test Run pytest test suite"
|
||||||
|
echo " update git pull + rebuild + restart dev stack"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Cloud (menagerie.circuitforge.tech/kiwi):"
|
echo "Cloud (menagerie.circuitforge.tech/kiwi):"
|
||||||
echo " cloud-start Build cloud images and start kiwi-cloud project"
|
echo " cloud-start Build cloud images and start kiwi-cloud project"
|
||||||
|
|
@ -34,6 +35,7 @@ usage() {
|
||||||
echo " cloud-status Show cloud containers"
|
echo " cloud-status Show cloud containers"
|
||||||
echo " cloud-logs Follow cloud logs [api|web — defaults to all]"
|
echo " cloud-logs Follow cloud logs [api|web — defaults to all]"
|
||||||
echo " cloud-build Rebuild cloud images without cache"
|
echo " cloud-build Rebuild cloud images without cache"
|
||||||
|
echo " cloud-update git pull + rebuild + restart cloud stack"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +70,11 @@ case "$cmd" in
|
||||||
build)
|
build)
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG build --no-cache
|
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)
|
test)
|
||||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG run --rm api \
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG run --rm api \
|
||||||
conda run -n job-seeker pytest tests/ -v
|
conda run -n job-seeker pytest tests/ -v
|
||||||
|
|
@ -95,6 +102,11 @@ case "$cmd" in
|
||||||
cloud-build)
|
cloud-build)
|
||||||
docker compose -f "$CLOUD_COMPOSE_FILE" -p "$CLOUD_PROJECT" build --no-cache
|
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
|
usage
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue