fixed rp api
This commit is contained in:
@@ -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)
|
||||
|
||||
Интеграции с домашней инфраструктурой:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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-теги)
|
||||
|
||||
@@ -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() {
|
||||
<NavLink to="/character">Персонаж</NavLink>
|
||||
<NavLink to="/memory">Память</NavLink>
|
||||
<NavLink to="/fitness">Фитнес</NavLink>
|
||||
<NavLink to="/shopping">Покупки</NavLink>
|
||||
<PomodoroWidget compact />
|
||||
</nav>
|
||||
</header>
|
||||
@@ -32,6 +34,7 @@ export default function App() {
|
||||
<Route path="/character" element={<Character />} />
|
||||
<Route path="/memory" element={<Memory />} />
|
||||
<Route path="/fitness" element={<Fitness />} />
|
||||
<Route path="/shopping" element={<Shopping />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -363,4 +363,75 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
}),
|
||||
|
||||
getShoppingSnapshot: () => request<ShoppingSnapshot>("/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;
|
||||
}
|
||||
|
||||
@@ -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 "система";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ShoppingSnapshot | null>(null);
|
||||
const [activeId, setActiveId] = useState<number | null>(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 (
|
||||
<div className="shopping-page">
|
||||
<aside className="shopping-sidebar">
|
||||
<h3>Списки</h3>
|
||||
<div className="shopping-list-nav">
|
||||
{(snapshot?.lists ?? []).map((list) => (
|
||||
<button
|
||||
key={list.id}
|
||||
type="button"
|
||||
className={`shopping-list-btn ${list.id === activeId ? "active" : ""}`}
|
||||
onClick={() => setActiveId(list.id)}
|
||||
>
|
||||
{list.name}
|
||||
<small>
|
||||
{list.unchecked_count} к покупке · {list.item_count} всего
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
{!snapshot?.lists.length && <div className="shopping-empty">Пока пусто</div>}
|
||||
</div>
|
||||
<form className="shopping-inline-form" onSubmit={handleCreateList}>
|
||||
<input
|
||||
value={newListName}
|
||||
onChange={(e) => setNewListName(e.target.value)}
|
||||
placeholder="Новый список"
|
||||
/>
|
||||
<button type="submit">+</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<section className="shopping-main">
|
||||
{activeList ? (
|
||||
<>
|
||||
<h2>{activeList.name}</h2>
|
||||
<div className="shopping-toolbar">
|
||||
<button type="button" onClick={() => load()} disabled={loading}>
|
||||
Обновить
|
||||
</button>
|
||||
<button type="button" onClick={clearChecked}>
|
||||
Убрать купленное
|
||||
</button>
|
||||
<button type="button" onClick={deleteList}>
|
||||
Удалить список
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="shopping-items">
|
||||
{(activeList.items ?? []).map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`shopping-item ${item.checked ? "checked" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onChange={() => toggleItem(item.id, item.checked)}
|
||||
/>
|
||||
<span className="shopping-item-text">
|
||||
{formatItemLabel(item.text, item.quantity, item.unit)}
|
||||
</span>
|
||||
<span className="shopping-item-meta">#{item.id}</span>
|
||||
<button type="button" onClick={() => removeItem(item.id)}>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<form className="shopping-inline-form" onSubmit={handleAddItem}>
|
||||
<input
|
||||
value={newItemText}
|
||||
onChange={(e) => setNewItemText(e.target.value)}
|
||||
placeholder="Добавить товар"
|
||||
/>
|
||||
<button type="submit">Добавить</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="shopping-empty">Выбери список или создай новый</div>
|
||||
)}
|
||||
{message && <div className="shopping-message">{message}</div>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user