diff --git a/README.md b/README.md index defc912..359bf9d 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,12 @@ lookup wger + Open Food Facts, напоминания в чат (`💪`), вкл Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3». +## Списки покупок + +Несколько списков, позиции с количеством, отметка «куплено». Вкладка `/shopping`, tools в чате (`add_shopping_items`, `list_shopping_lists`, …). + +Чат: «добавь молоко и хлеб в продукты», «что в списке покупок», «отметь молоко купленным». + ## Homelab API (фаза 4) Интеграции с домашней инфраструктурой: diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index adb3d33..7312056 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, webhooks +from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, shopping, webhooks api_router = APIRouter(prefix="/api/v1") api_router.include_router(health.router, tags=["health"]) @@ -11,5 +11,6 @@ api_router.include_router(character.router, tags=["character"]) api_router.include_router(projects.router, tags=["projects"]) api_router.include_router(memory.router, tags=["memory"]) api_router.include_router(fitness.router, tags=["fitness"]) +api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"]) api_router.include_router(webhooks.router, tags=["webhooks"]) api_router.include_router(media.router, tags=["media"]) diff --git a/backend/app/api/routes/shopping.py b/backend/app/api/routes/shopping.py new file mode 100644 index 0000000..cd96f67 --- /dev/null +++ b/backend/app/api/routes/shopping.py @@ -0,0 +1,116 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.db.base import get_db +from app.shopping.service import ShoppingService + +router = APIRouter() + + +class ListCreate(BaseModel): + name: str = Field(min_length=1, max_length=255) + + +class ListRename(BaseModel): + name: str = Field(min_length=1, max_length=255) + + +class ItemInput(BaseModel): + text: str = Field(min_length=1, max_length=500) + quantity: float | None = None + unit: str = "" + + +class ItemsAdd(BaseModel): + list_id: int | None = None + list_name: str | None = None + items: list[ItemInput] = Field(min_length=1) + + +class ItemChecked(BaseModel): + checked: bool + + +@router.get("") +def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]: + return ShoppingService(db).snapshot() + + +@router.get("/lists") +def list_lists(db: Session = Depends(get_db)) -> list[dict[str, Any]]: + return ShoppingService(db).list_lists(include_items=True) + + +@router.post("/lists") +def create_list(payload: ListCreate, db: Session = Depends(get_db)) -> dict[str, Any]: + try: + return ShoppingService(db).create_list(payload.name) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/lists/{list_id}") +def get_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: + data = ShoppingService(db).get_list(list_id=list_id) + if not data: + raise HTTPException(status_code=404, detail="List not found") + return data + + +@router.patch("/lists/{list_id}") +def rename_list(list_id: int, payload: ListRename, db: Session = Depends(get_db)) -> dict[str, Any]: + try: + return ShoppingService(db).rename_list(list_id, payload.name) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.delete("/lists/{list_id}") +def delete_list(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: + try: + return ShoppingService(db).delete_list(list_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/items") +def add_items(payload: ItemsAdd, db: Session = Depends(get_db)) -> dict[str, Any]: + try: + return ShoppingService(db).add_items( + [i.model_dump() for i in payload.items], + list_id=payload.list_id, + list_name=payload.list_name, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/items/{item_id}") +def set_item_checked( + item_id: int, + payload: ItemChecked, + db: Session = Depends(get_db), +) -> dict[str, Any]: + try: + return ShoppingService(db).set_item_checked(item_id, payload.checked) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/items/{item_id}") +def remove_item(item_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: + try: + return ShoppingService(db).remove_item(item_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/lists/{list_id}/clear-checked") +def clear_checked(list_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: + try: + return ShoppingService(db).clear_checked(list_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/backend/app/character/card.py b/backend/app/character/card.py index 69d3983..d3a49fb 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -23,6 +23,8 @@ TOOLS_INSTRUCTIONS = """ - Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай. - Утренний брифинг (погода + новости) → get_morning_briefing. - Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй. +- Покупки: list_shopping_lists, create_shopping_list, add_shopping_items, check_shopping_item, remove_shopping_item, delete_shopping_list. +- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки. """.strip() DEFAULT_CARD: dict[str, Any] = { diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index b5313fa..4ed1027 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -69,6 +69,15 @@ FITNESS_TOOL_NAMES = frozenset({ }) # Не засорять чат служебными ответами +SHOPPING_TOOL_NAMES = frozenset({ + "list_shopping_lists", + "create_shopping_list", + "add_shopping_items", + "check_shopping_item", + "remove_shopping_item", + "delete_shopping_list", +}) + TOOLS_SKIP_CHAT_NOTICE = frozenset({ "get_pomodoro_status", "recall_memories", @@ -78,6 +87,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({ "calc_fitness_targets", "get_weather", "get_morning_briefing", + "list_shopping_lists", }) @@ -97,6 +107,8 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None: prefix = "🧠" elif tool_name in FITNESS_TOOL_NAMES: prefix = "💪" + elif tool_name in SHOPPING_TOOL_NAMES: + prefix = "🛒" else: prefix = "📋" return f"{prefix} {data['error']}" @@ -196,6 +208,29 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None: url = data.get("url", "") return f"🎨 **Картинка готова**\n\n" + if tool_name == "create_shopping_list" and data.get("ok"): + lst = data.get("list") or {} + action = "создан" if data.get("created") else "уже был" + return f"🛒 **Список {action}** · «{lst.get('name')}» (#{lst.get('id')})" + + if tool_name == "add_shopping_items" and data.get("ok"): + added = data.get("added") or [] + names = ", ".join(i.get("text", "") for i in added[:5]) + extra = f" +{len(added) - 5}" if len(added) > 5 else "" + return f"🛒 **Добавлено в «{data.get('list_name')}»** · {names}{extra}" + + if tool_name == "check_shopping_item" and data.get("ok"): + item = data.get("item") or {} + state = "куплено" if item.get("checked") else "снята отметка" + return f"🛒 **{state}** · #{item.get('id')} {item.get('text')}" + + if tool_name == "remove_shopping_item" and data.get("ok"): + removed = data.get("removed") or {} + return f"🛒 **Удалено** · {removed.get('text')}" + + if tool_name == "delete_shopping_list" and data.get("ok"): + return f"🛒 **Список удалён** · «{data.get('name')}»" + return None diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index 2507928..c0a31bf 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -22,6 +22,7 @@ from app.memory.context import ( ) from app.memory.extract import extract_after_turn from app.projects.context import format_projects_context, get_projects_snapshot +from app.shopping.context import format_shopping_context, get_shopping_snapshot from app.db.models import ChatSession, Message from app.llm.client import LLMClient from app.pomodoro.service import PomodoroService @@ -63,12 +64,14 @@ class ChatService: status = PomodoroService(self.db).get_status() memory_snapshot = get_memory_snapshot(self.db, session_id) fitness_snapshot = get_fitness_snapshot(self.db) + shopping_snapshot = get_shopping_snapshot(self.db) projects_snapshot = get_projects_snapshot(self.db) return ( f"{self.character.get_system_prompt()}\n\n" f"{format_datetime_context(self.db)}\n\n" f"{format_memory_context(memory_snapshot)}\n\n" f"{format_fitness_context(fitness_snapshot)}\n\n" + f"{format_shopping_context(shopping_snapshot)}\n\n" f"{format_weather_snapshot()}\n\n" f"{format_pomodoro_context(status)}\n\n" f"{format_projects_context(projects_snapshot)}" diff --git a/backend/app/db/models.py b/backend/app/db/models.py index d323ab2..009d492 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -215,6 +215,41 @@ class FitnessReminder(Base): last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) +class ShoppingList(Base): + __tablename__ = "shopping_lists" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), unique=True, index=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + items: Mapped[list["ShoppingListItem"]] = relationship( + back_populates="shopping_list", + cascade="all, delete-orphan", + order_by="ShoppingListItem.sort_order, ShoppingListItem.id", + ) + + +class ShoppingListItem(Base): + __tablename__ = "shopping_list_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + list_id: Mapped[int] = mapped_column( + ForeignKey("shopping_lists.id", ondelete="CASCADE"), index=True + ) + text: Mapped[str] = mapped_column(String(500)) + quantity: Mapped[float | None] = mapped_column(Float, nullable=True) + unit: Mapped[str] = mapped_column(String(64), default="") + checked: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items") + + class AssistantState(Base): __tablename__ = "assistant_state" diff --git a/backend/app/shopping/__init__.py b/backend/app/shopping/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/shopping/context.py b/backend/app/shopping/context.py new file mode 100644 index 0000000..926262a --- /dev/null +++ b/backend/app/shopping/context.py @@ -0,0 +1,47 @@ +from typing import Any + +from sqlalchemy.orm import Session + +from app.shopping.service import ShoppingService + +MAX_LISTS_IN_CONTEXT = 8 +MAX_ITEMS_PER_LIST = 12 + + +def get_shopping_snapshot(db: Session) -> dict[str, Any]: + return ShoppingService(db).snapshot() + + +def format_shopping_context(snapshot: dict[str, Any]) -> str: + lines = ["[Списки покупок]"] + lists = snapshot.get("lists") or [] + + if not lists: + lines.append("Списков пока нет. create_shopping_list или add_shopping_items.") + return "\n".join(lines) + + lines.append( + f"Всего списков: {snapshot.get('list_count', len(lists))}, " + f"неотмеченных позиций: {snapshot.get('unchecked_items', 0)}." + ) + lines.append("Для изменений вызывай tools: list_shopping_lists, add_shopping_items, check_shopping_item.") + + for lst in lists[:MAX_LISTS_IN_CONTEXT]: + items = lst.get("items") or [] + unchecked = [i for i in items if not i.get("checked")] + preview = unchecked[:MAX_ITEMS_PER_LIST] + parts = [] + for item in preview: + qty = item.get("quantity") + unit = (item.get("unit") or "").strip() + label = item["text"] + if qty is not None: + label = f"{label} ({qty}{' ' + unit if unit else ''})" + parts.append(f"#{item['id']} {label}") + tail = f" +{len(unchecked) - len(preview)} ещё" if len(unchecked) > len(preview) else "" + if parts: + lines.append(f"- «{lst['name']}» (#{lst['id']}): {', '.join(parts)}{tail}") + else: + lines.append(f"- «{lst['name']}» (#{lst['id']}): всё отмечено или пусто") + + return "\n".join(lines) diff --git a/backend/app/shopping/service.py b/backend/app/shopping/service.py new file mode 100644 index 0000000..2b7804a --- /dev/null +++ b/backend/app/shopping/service.py @@ -0,0 +1,223 @@ +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from app.db.models import ShoppingList, ShoppingListItem + + +class ShoppingService: + def __init__(self, db: Session): + self.db = db + + def snapshot(self) -> dict[str, Any]: + lists = self.list_lists(include_items=True) + total_items = sum(len(lst.get("items") or []) for lst in lists) + unchecked = sum( + 1 + for lst in lists + for item in (lst.get("items") or []) + if not item.get("checked") + ) + return { + "lists": lists, + "list_count": len(lists), + "total_items": total_items, + "unchecked_items": unchecked, + } + + def list_lists(self, *, include_items: bool = False) -> list[dict[str, Any]]: + stmt = select(ShoppingList).order_by(ShoppingList.sort_order, ShoppingList.name) + if include_items: + stmt = stmt.options(selectinload(ShoppingList.items)) + rows = list(self.db.scalars(stmt).all()) + return [self._list_to_dict(row, include_items=include_items) for row in rows] + + def get_list( + self, + list_id: int | None = None, + *, + name: str | None = None, + ) -> dict[str, Any] | None: + row = self._resolve_list(list_id=list_id, name=name) + if not row: + return None + return self._list_to_dict(row, include_items=True) + + def create_list(self, name: str) -> dict[str, Any]: + clean = name.strip() + if not clean: + raise ValueError("Название списка не может быть пустым") + existing = self.db.scalar(select(ShoppingList).where(ShoppingList.name == clean)) + if existing: + return {"ok": True, "list": self._list_to_dict(existing, include_items=True), "created": False} + + max_order = self.db.scalar(select(func.max(ShoppingList.sort_order))) or 0 + row = ShoppingList(name=clean, sort_order=max_order + 1) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "list": self._list_to_dict(row, include_items=True), "created": True} + + def rename_list(self, list_id: int, name: str) -> dict[str, Any]: + row = self._require_list(list_id) + clean = name.strip() + if not clean: + raise ValueError("Название списка не может быть пустым") + conflict = self.db.scalar( + select(ShoppingList).where(ShoppingList.name == clean, ShoppingList.id != list_id) + ) + if conflict: + raise ValueError(f"Список «{clean}» уже существует") + row.name = clean + row.updated_at = datetime.now(timezone.utc) + self.db.commit() + self.db.refresh(row) + return {"ok": True, "list": self._list_to_dict(row, include_items=True)} + + def delete_list(self, list_id: int) -> dict[str, Any]: + row = self._require_list(list_id) + name = row.name + self.db.delete(row) + self.db.commit() + return {"ok": True, "deleted_list_id": list_id, "name": name} + + def add_items( + self, + items: list[dict[str, Any]], + *, + list_id: int | None = None, + list_name: str | None = None, + create_list: bool = True, + ) -> dict[str, Any]: + if not items: + raise ValueError("Нужен хотя бы один товар") + + row = self._resolve_list(list_id=list_id, name=list_name) + if not row and list_name and create_list: + created = self.create_list(list_name) + row = self._require_list(created["list"]["id"]) + if not row: + raise ValueError("Укажи list_id или list_name") + + max_order = self.db.scalar( + select(func.max(ShoppingListItem.sort_order)).where(ShoppingListItem.list_id == row.id) + ) or 0 + added: list[dict[str, Any]] = [] + for idx, raw in enumerate(items, start=1): + text = (raw.get("text") or "").strip() + if not text: + continue + item = ShoppingListItem( + list_id=row.id, + text=text, + quantity=raw.get("quantity"), + unit=(raw.get("unit") or "").strip(), + sort_order=max_order + idx, + ) + self.db.add(item) + added.append(item) + + if not added: + raise ValueError("Нет валидных товаров для добавления") + + row.updated_at = datetime.now(timezone.utc) + self.db.commit() + for item in added: + self.db.refresh(item) + + return { + "ok": True, + "list_id": row.id, + "list_name": row.name, + "added": [self._item_to_dict(i) for i in added], + "list": self._list_to_dict(self._require_list(row.id), include_items=True), + } + + def set_item_checked(self, item_id: int, checked: bool) -> dict[str, Any]: + item = self._require_item(item_id) + item.checked = checked + item.shopping_list.updated_at = datetime.now(timezone.utc) + self.db.commit() + return {"ok": True, "item": self._item_to_dict(item)} + + def remove_item(self, item_id: int) -> dict[str, Any]: + item = self._require_item(item_id) + data = self._item_to_dict(item) + list_id = item.list_id + self.db.delete(item) + self.db.commit() + return {"ok": True, "removed": data, "list_id": list_id} + + def clear_checked(self, list_id: int) -> dict[str, Any]: + row = self._require_list(list_id) + removed = 0 + for item in list(row.items): + if item.checked: + self.db.delete(item) + removed += 1 + row.updated_at = datetime.now(timezone.utc) + self.db.commit() + return { + "ok": True, + "list_id": list_id, + "removed_count": removed, + "list": self._list_to_dict(self._require_list(list_id), include_items=True), + } + + def _resolve_list( + self, + *, + list_id: int | None = None, + name: str | None = None, + ) -> ShoppingList | None: + if list_id is not None: + return self.db.scalar( + select(ShoppingList) + .where(ShoppingList.id == list_id) + .options(selectinload(ShoppingList.items)) + ) + if name: + clean = name.strip() + return self.db.scalar( + select(ShoppingList) + .where(ShoppingList.name == clean) + .options(selectinload(ShoppingList.items)) + ) + return None + + def _require_list(self, list_id: int) -> ShoppingList: + row = self._resolve_list(list_id=list_id) + if not row: + raise ValueError(f"Список #{list_id} не найден") + return row + + def _require_item(self, item_id: int) -> ShoppingListItem: + item = self.db.get(ShoppingListItem, item_id) + if not item: + raise ValueError(f"Позиция #{item_id} не найдена") + return item + + def _item_to_dict(self, item: ShoppingListItem) -> dict[str, Any]: + return { + "id": item.id, + "list_id": item.list_id, + "text": item.text, + "quantity": item.quantity, + "unit": item.unit, + "checked": item.checked, + "sort_order": item.sort_order, + } + + def _list_to_dict(self, row: ShoppingList, *, include_items: bool) -> dict[str, Any]: + data: dict[str, Any] = { + "id": row.id, + "name": row.name, + "sort_order": row.sort_order, + "item_count": len(row.items) if row.items is not None else 0, + "unchecked_count": sum(1 for i in (row.items or []) if not i.checked), + } + if include_items: + data["items"] = [self._item_to_dict(i) for i in (row.items or [])] + return data diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index ada25bc..80ba259 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -13,6 +13,7 @@ from app.integrations.wger import WgerClient from app.memory.service import MemoryService from app.pomodoro.service import PomodoroService from app.projects.service import ProjectService +from app.shopping.service import ShoppingService TOOL_DEFINITIONS: list[dict[str, Any]] = [ { @@ -496,6 +497,94 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ }, }, }, + { + "type": "function", + "function": { + "name": "list_shopping_lists", + "description": "Все списки покупок с позициями. «Что купить», «покажи списки».", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }, + { + "type": "function", + "function": { + "name": "create_shopping_list", + "description": "Создать новый список покупок.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Название списка, например «Продукты»"}, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "add_shopping_items", + "description": "Добавить товары в список. Список создаётся, если не существует.", + "parameters": { + "type": "object", + "properties": { + "list_name": {"type": "string", "description": "Название списка"}, + "list_id": {"type": "integer"}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "quantity": {"type": "number"}, + "unit": {"type": "string"}, + }, + "required": ["text"], + }, + }, + }, + "required": ["items"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_shopping_item", + "description": "Отметить позицию как купленную (checked=true) или снять отметку (false).", + "parameters": { + "type": "object", + "properties": { + "item_id": {"type": "integer"}, + "checked": {"type": "boolean"}, + }, + "required": ["item_id", "checked"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "remove_shopping_item", + "description": "Удалить позицию из списка по item_id.", + "parameters": { + "type": "object", + "properties": {"item_id": {"type": "integer"}}, + "required": ["item_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "delete_shopping_list", + "description": "Удалить весь список покупок.", + "parameters": { + "type": "object", + "properties": {"list_id": {"type": "integer"}}, + "required": ["list_id"], + }, + }, + }, { "type": "function", "function": { @@ -528,6 +617,7 @@ async def execute_tool( projects = ProjectService(db) memory = MemoryService(db) fitness = FitnessService(db) + shopping = ShoppingService(db) try: if name == "get_pomodoro_status": @@ -680,6 +770,25 @@ async def execute_tool( draw_self=bool(arguments.get("draw_self")), scene_description=arguments.get("scene_description", ""), ) + elif name == "list_shopping_lists": + result = shopping.list_lists(include_items=True) + elif name == "create_shopping_list": + result = shopping.create_list(arguments.get("name", "")) + elif name == "add_shopping_items": + result = shopping.add_items( + arguments.get("items") or [], + list_id=arguments.get("list_id"), + list_name=arguments.get("list_name"), + ) + elif name == "check_shopping_item": + result = shopping.set_item_checked( + int(arguments["item_id"]), + bool(arguments.get("checked", True)), + ) + elif name == "remove_shopping_item": + result = shopping.remove_item(int(arguments["item_id"])) + elif name == "delete_shopping_list": + result = shopping.delete_list(int(arguments["list_id"])) else: return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) diff --git a/backend/prompts/assistant.md b/backend/prompts/assistant.md index 2f1cb51..8ae58f6 100644 --- a/backend/prompts/assistant.md +++ b/backend/prompts/assistant.md @@ -25,6 +25,10 @@ - Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода] - Утренний брифинг — get_morning_briefing +Списки покупок: +- Несколько списков (Продукты, Дом, и т.д.) +- add_shopping_items, list_shopping_lists, check_shopping_item + Картинки: - «Нарисуй себя» → generate_image с draw_self=true - Другая сцена → generate_image с scene_description на английском (booru-теги) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ffc5943..c697109 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { PomodoroProvider } from "./context/PomodoroContext"; import Character from "./pages/Character"; import Chat from "./pages/Chat"; import Fitness from "./pages/Fitness"; +import Shopping from "./pages/Shopping"; import Memory from "./pages/Memory"; import Pomodoro from "./pages/Pomodoro"; import "./App.css"; @@ -22,6 +23,7 @@ export default function App() { Персонаж Память Фитнес + Покупки @@ -32,6 +34,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 94b4872..54c7548 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -363,4 +363,75 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }), + + getShoppingSnapshot: () => request("/api/v1/shopping"), + + createShoppingList: (name: string) => + request<{ ok: boolean; list: ShoppingList }>("/api/v1/shopping/lists", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }), + + renameShoppingList: (listId: number, name: string) => + request<{ ok: boolean; list: ShoppingList }>(`/api/v1/shopping/lists/${listId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }), + + deleteShoppingList: (listId: number) => + request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}`, { method: "DELETE" }), + + addShoppingItems: (payload: { + list_id?: number; + list_name?: string; + items: { text: string; quantity?: number; unit?: string }[]; + }) => + request<{ ok: boolean }>("/api/v1/shopping/items", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + + setShoppingItemChecked: (itemId: number, checked: boolean) => + request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ checked }), + }), + + removeShoppingItem: (itemId: number) => + request<{ ok: boolean }>(`/api/v1/shopping/items/${itemId}`, { method: "DELETE" }), + + clearShoppingChecked: (listId: number) => + request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, { + method: "POST", + }), }; + +export interface ShoppingListItem { + id: number; + list_id: number; + text: string; + quantity: number | null; + unit: string; + checked: boolean; + sort_order: number; +} + +export interface ShoppingList { + id: number; + name: string; + sort_order: number; + item_count: number; + unchecked_count: number; + items?: ShoppingListItem[]; +} + +export interface ShoppingSnapshot { + lists: ShoppingList[]; + list_count: number; + total_items: number; + unchecked_items: number; +} diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 43d7333..69f9708 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -20,6 +20,7 @@ function noticeLabel(content: string): string { if (content.startsWith("🌤")) return "погода"; if (content.startsWith("🎨")) return "картинка"; if (content.startsWith("⚠️")) return "сервер"; + if (content.startsWith("🛒")) return "покупки"; return "система"; } diff --git a/frontend/src/pages/Shopping.css b/frontend/src/pages/Shopping.css new file mode 100644 index 0000000..82c0906 --- /dev/null +++ b/frontend/src/pages/Shopping.css @@ -0,0 +1,132 @@ +.shopping-page { + display: grid; + grid-template-columns: 240px 1fr; + gap: 1rem; + height: calc(100vh - 80px); + padding: 1rem; +} + +.shopping-sidebar, +.shopping-main { + background: #12151c; + border: 1px solid #2f3748; + border-radius: 12px; + padding: 1rem; + overflow: auto; +} + +.shopping-sidebar h3, +.shopping-main h2 { + margin: 0 0 1rem; +} + +.shopping-list-nav { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.shopping-list-btn { + text-align: left; + background: #1a1f2b; + border: 1px solid #2f3748; + color: inherit; + border-radius: 8px; + padding: 0.6rem 0.75rem; + cursor: pointer; +} + +.shopping-list-btn.active { + border-color: #4a7cff; + background: #1c2740; +} + +.shopping-list-btn small { + display: block; + color: #8b95a5; + margin-top: 0.2rem; +} + +.shopping-inline-form { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} + +.shopping-inline-form input { + flex: 1; + border-radius: 8px; + border: 1px solid #2f3748; + background: #0f1218; + color: inherit; + padding: 0.55rem 0.7rem; +} + +.shopping-inline-form button, +.shopping-toolbar button { + border-radius: 8px; + border: 1px solid #3a4558; + background: #2b3445; + color: inherit; + padding: 0.5rem 0.8rem; + cursor: pointer; +} + +.shopping-toolbar { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.shopping-items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.shopping-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 0.75rem; + border-radius: 8px; + background: #1a1f2b; + border: 1px solid #2a3140; +} + +.shopping-item.checked { + opacity: 0.55; +} + +.shopping-item.checked .shopping-item-text { + text-decoration: line-through; +} + +.shopping-item-text { + flex: 1; +} + +.shopping-item-meta { + color: #8b95a5; + font-size: 0.85rem; +} + +.shopping-empty { + color: #8b95a5; +} + +.shopping-message { + margin-top: 0.75rem; + color: #8b95a5; +} + +@media (max-width: 900px) { + .shopping-page { + grid-template-columns: 1fr; + height: auto; + } +} diff --git a/frontend/src/pages/Shopping.tsx b/frontend/src/pages/Shopping.tsx new file mode 100644 index 0000000..8b0cb5a --- /dev/null +++ b/frontend/src/pages/Shopping.tsx @@ -0,0 +1,180 @@ +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { api, ShoppingList, ShoppingSnapshot } from "../api/client"; +import "./Shopping.css"; + +function formatItemLabel(text: string, quantity: number | null, unit: string) { + if (quantity == null) return text; + const u = unit.trim(); + return u ? `${text} — ${quantity} ${u}` : `${text} — ${quantity}`; +} + +export default function Shopping() { + const [snapshot, setSnapshot] = useState(null); + const [activeId, setActiveId] = useState(null); + const [newListName, setNewListName] = useState(""); + const [newItemText, setNewItemText] = useState(""); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const data = await api.getShoppingSnapshot(); + setSnapshot(data); + setActiveId((current) => { + if (!current && data.lists.length > 0) return data.lists[0].id; + if (current && !data.lists.some((l) => l.id === current)) { + return data.lists[0]?.id ?? null; + } + return current; + }); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка загрузки"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load().catch(console.error); + }, [load]); + + const activeList: ShoppingList | undefined = snapshot?.lists.find((l) => l.id === activeId); + + const handleCreateList = async (e: FormEvent) => { + e.preventDefault(); + if (!newListName.trim()) return; + try { + const res = await api.createShoppingList(newListName.trim()); + setNewListName(""); + setActiveId(res.list.id); + await load(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка"); + } + }; + + const handleAddItem = async (e: FormEvent) => { + e.preventDefault(); + if (!activeList || !newItemText.trim()) return; + try { + await api.addShoppingItems({ + list_id: activeList.id, + items: [{ text: newItemText.trim() }], + }); + setNewItemText(""); + await load(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка"); + } + }; + + const toggleItem = async (itemId: number, checked: boolean) => { + await api.setShoppingItemChecked(itemId, !checked); + await load(); + }; + + const removeItem = async (itemId: number) => { + await api.removeShoppingItem(itemId); + await load(); + }; + + const clearChecked = async () => { + if (!activeList) return; + await api.clearShoppingChecked(activeList.id); + await load(); + }; + + const deleteList = async () => { + if (!activeList) return; + if (!confirm(`Удалить список «${activeList.name}»?`)) return; + await api.deleteShoppingList(activeList.id); + setActiveId(null); + await load(); + }; + + return ( + + + + + {activeList ? ( + <> + {activeList.name} + + load()} disabled={loading}> + Обновить + + + Убрать купленное + + + Удалить список + + + + + {(activeList.items ?? []).map((item) => ( + + toggleItem(item.id, item.checked)} + /> + + {formatItemLabel(item.text, item.quantity, item.unit)} + + #{item.id} + removeItem(item.id)}> + × + + + ))} + + + + setNewItemText(e.target.value)} + placeholder="Добавить товар" + /> + Добавить + + > + ) : ( + Выбери список или создай новый + )} + {message && {message}} + + + ); +}