diff --git a/.env.example b/.env.example index 019b58b..373f1ce 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,7 @@ MEMORY_AUTO_EXTRACT=true WGER_BASE_URL=https://wger.de/api/v2 OPENFOODFACTS_BASE_URL=https://world.openfoodfacts.org FITNESS_REMINDERS_ENABLED=true +REMINDERS_ENABLED=true # Taiga (on host :9000, nginx → taiga.grigowashere.ru) TAIGA_BASE_URL=http://host.docker.internal:9000 diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 7312056..a69fb85 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, shopping, webhooks +from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, reminders, shopping, webhooks api_router = APIRouter(prefix="/api/v1") api_router.include_router(health.router, tags=["health"]) @@ -12,5 +12,6 @@ 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(reminders.router, prefix="/reminders", tags=["reminders"]) api_router.include_router(webhooks.router, tags=["webhooks"]) api_router.include_router(media.router, tags=["media"]) diff --git a/backend/app/api/routes/reminders.py b/backend/app/api/routes/reminders.py new file mode 100644 index 0000000..f6ec114 --- /dev/null +++ b/backend/app/api/routes/reminders.py @@ -0,0 +1,124 @@ +from datetime import datetime, timezone +from typing import Any +from zoneinfo import ZoneInfo + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.db.base import get_db +from app.homelab.context import resolve_timezone +from app.reminders.service import RemindersService + +router = APIRouter() + + +class ReminderCreate(BaseModel): + title: str = Field(min_length=1, max_length=255) + due_at: str = Field(description="ISO datetime, например 2027-05-12T12:16:00") + notes: str = "" + all_day: bool = False + recurrence: str = "none" + + +class ReminderUpdate(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=255) + due_at: str | None = None + notes: str | None = None + all_day: bool | None = None + recurrence: str | None = None + enabled: bool | None = None + + +@router.get("") +def get_snapshot(db: Session = Depends(get_db)) -> dict[str, Any]: + return RemindersService(db).snapshot() + + +@router.get("/upcoming") +def list_upcoming( + limit: int = Query(30, ge=1, le=100), + db: Session = Depends(get_db), +) -> list[dict[str, Any]]: + return RemindersService(db).list_upcoming(limit=limit) + + +@router.get("/calendar") +def calendar( + year: int = Query(..., ge=2000, le=2100), + month: int = Query(..., ge=1, le=12), + db: Session = Depends(get_db), +) -> dict[str, Any]: + tz_name = resolve_timezone(db) + try: + tz = ZoneInfo(tz_name) + except Exception: + tz = ZoneInfo("Europe/Moscow") + + start = datetime(year, month, 1, tzinfo=tz) + if month == 12: + end = datetime(year + 1, 1, 1, tzinfo=tz) + else: + end = datetime(year, month + 1, 1, tzinfo=tz) + + service = RemindersService(db) + items = service.list_in_range( + date_from=start.astimezone(timezone.utc), + date_to=end.astimezone(timezone.utc), + ) + return { + "year": year, + "month": month, + "timezone": tz_name, + "reminders": items, + } + + +@router.post("") +def create_reminder(payload: ReminderCreate, db: Session = Depends(get_db)) -> dict[str, Any]: + try: + return RemindersService(db).create( + title=payload.title, + due_at=payload.due_at, + notes=payload.notes, + all_day=payload.all_day, + recurrence=payload.recurrence, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/{reminder_id}") +def update_reminder( + reminder_id: int, + payload: ReminderUpdate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + try: + return RemindersService(db).update( + reminder_id, + title=payload.title, + due_at=payload.due_at, + notes=payload.notes, + all_day=payload.all_day, + recurrence=payload.recurrence, + enabled=payload.enabled, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/{reminder_id}") +def delete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: + try: + return RemindersService(db).delete(reminder_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/{reminder_id}/complete") +def complete_reminder(reminder_id: int, db: Session = Depends(get_db)) -> dict[str, Any]: + try: + return RemindersService(db).complete(reminder_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 d3a49fb..d885488 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -25,6 +25,9 @@ TOOLS_INSTRUCTIONS = """ - Картинки: 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. Не выдумывай списки. +- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder. +- «Напомни через 15 минут», «завтра утром», «12 мая 2027 в 12:16» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]). +- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе. """.strip() DEFAULT_CARD: dict[str, Any] = { diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index 4ed1027..d60566d 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -69,6 +69,14 @@ FITNESS_TOOL_NAMES = frozenset({ }) # Не засорять чат служебными ответами +REMINDER_TOOL_NAMES = frozenset({ + "list_reminders", + "create_reminder", + "update_reminder", + "delete_reminder", + "complete_reminder", +}) + SHOPPING_TOOL_NAMES = frozenset({ "list_shopping_lists", "create_shopping_list", @@ -88,6 +96,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({ "get_weather", "get_morning_briefing", "list_shopping_lists", + "list_reminders", }) @@ -109,6 +118,8 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None: prefix = "💪" elif tool_name in SHOPPING_TOOL_NAMES: prefix = "🛒" + elif tool_name in REMINDER_TOOL_NAMES: + prefix = "📅" else: prefix = "📋" return f"{prefix} {data['error']}" @@ -231,6 +242,23 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None: if tool_name == "delete_shopping_list" and data.get("ok"): return f"🛒 **Список удалён** · «{data.get('name')}»" + if tool_name == "create_reminder" and data.get("ok"): + r = data.get("reminder") or {} + rec = r.get("recurrence", "none") + rec_label = f" · повтор {rec}" if rec and rec != "none" else "" + return f"📅 **Напоминание создано** · {r.get('title')} · {r.get('due_at_local')}{rec_label}" + + if tool_name == "update_reminder" and data.get("ok"): + r = data.get("reminder") or {} + return f"📅 **Напоминание обновлено** · #{r.get('id')} {r.get('title')}" + + if tool_name == "delete_reminder" and data.get("ok"): + return f"📅 **Напоминание удалено** · «{data.get('title')}»" + + if tool_name == "complete_reminder" and data.get("ok"): + r = data.get("reminder") or {} + return f"📅 **Готово** · {r.get('title')}" + return None diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py index e91f4cc..cdc4dec 100644 --- a/backend/app/chat/service.py +++ b/backend/app/chat/service.py @@ -28,6 +28,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.reminders.context import format_reminders_context, get_reminders_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 @@ -99,6 +100,7 @@ class ChatService: memory_snapshot = get_memory_snapshot(self.db, session_id) fitness_snapshot = get_fitness_snapshot(self.db) shopping_snapshot = get_shopping_snapshot(self.db) + reminders_snapshot = get_reminders_snapshot(self.db) projects_snapshot = get_projects_snapshot(self.db) return ( f"{self.character.get_system_prompt()}\n\n" @@ -106,6 +108,7 @@ class ChatService: 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_reminders_context(reminders_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/config.py b/backend/app/config.py index 01e614d..3276f62 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -45,6 +45,7 @@ class Settings(BaseSettings): wger_base_url: str = "https://wger.de/api/v2" openfoodfacts_base_url: str = "https://world.openfoodfacts.org" fitness_reminders_enabled: bool = True + reminders_enabled: bool = True openmeteo_base_url: str = "http://192.168.1.109:8085" weather_lat: float = 59.9343 diff --git a/backend/app/db/models.py b/backend/app/db/models.py index c31f4a1..394de86 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -251,6 +251,25 @@ class ShoppingListItem(Base): shopping_list: Mapped["ShoppingList"] = relationship(back_populates="items") +class Reminder(Base): + __tablename__ = "reminders" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(String(255)) + notes: Mapped[str] = mapped_column(Text, default="") + due_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + all_day: Mapped[bool] = mapped_column(Boolean, default=False) + recurrence: Mapped[str] = mapped_column(String(16), default="none") + enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Europe/Moscow") + 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() + ) + + class AssistantState(Base): __tablename__ = "assistant_state" diff --git a/backend/app/main.py b/backend/app/main.py index 41311d8..387bac0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from app.db.base import init_db from app.fitness.watcher import fitness_watcher_loop from app.homelab.watcher import homelab_watcher_loop from app.pomodoro.watcher import pomodoro_watcher_loop +from app.reminders.watcher import reminders_watcher_loop @asynccontextmanager @@ -18,16 +19,20 @@ async def lifespan(_: FastAPI): pomodoro_task = asyncio.create_task(pomodoro_watcher_loop()) fitness_task = asyncio.create_task(fitness_watcher_loop()) homelab_task = asyncio.create_task(homelab_watcher_loop()) + reminders_task = asyncio.create_task(reminders_watcher_loop()) yield pomodoro_task.cancel() fitness_task.cancel() homelab_task.cancel() + reminders_task.cancel() with suppress(asyncio.CancelledError): await pomodoro_task with suppress(asyncio.CancelledError): await fitness_task with suppress(asyncio.CancelledError): await homelab_task + with suppress(asyncio.CancelledError): + await reminders_task def create_app() -> FastAPI: diff --git a/backend/app/reminders/__init__.py b/backend/app/reminders/__init__.py new file mode 100644 index 0000000..0978baf --- /dev/null +++ b/backend/app/reminders/__init__.py @@ -0,0 +1,3 @@ +from app.reminders.service import RemindersService + +__all__ = ["RemindersService"] diff --git a/backend/app/reminders/context.py b/backend/app/reminders/context.py new file mode 100644 index 0000000..981f4b4 --- /dev/null +++ b/backend/app/reminders/context.py @@ -0,0 +1,33 @@ +from typing import Any + +from sqlalchemy.orm import Session + +from app.reminders.service import RemindersService + +MAX_IN_CONTEXT = 10 + + +def get_reminders_snapshot(db: Session) -> dict[str, Any]: + return RemindersService(db).snapshot() + + +def format_reminders_context(snapshot: dict[str, Any]) -> str: + lines = ["[Напоминания]"] + upcoming = snapshot.get("upcoming") or [] + tz = snapshot.get("timezone", "Europe/Moscow") + + if not upcoming: + lines.append( + "Ближайших напоминаний нет. " + "create_reminder для «напомни через 15 минут», «завтра утром», точной даты." + ) + return "\n".join(lines) + + lines.append(f"Часовой пояс: {tz}. Tools: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.") + for item in upcoming[:MAX_IN_CONTEXT]: + rec = item.get("recurrence", "none") + rec_label = f" · повтор: {rec}" if rec and rec != "none" else "" + lines.append( + f"- #{item['id']} **{item['title']}** · {item.get('due_at_local', item.get('due_at'))}{rec_label}" + ) + return "\n".join(lines) diff --git a/backend/app/reminders/fire.py b/backend/app/reminders/fire.py new file mode 100644 index 0000000..f1d3cee --- /dev/null +++ b/backend/app/reminders/fire.py @@ -0,0 +1,60 @@ +import logging +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.chat.notice_inbox import post_notice_to_latest_chat +from app.db.models import Reminder +from app.reminders.notify import bump_notify_seq +from app.reminders.service import RECURRENCE_NONE, _advance_due, _format_local + +logger = logging.getLogger(__name__) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def check_due_reminders(db: Session) -> int: + now = _utcnow() + stmt = ( + select(Reminder) + .where( + Reminder.enabled.is_(True), + Reminder.completed_at.is_(None), + Reminder.due_at <= now, + ) + .order_by(Reminder.due_at.asc()) + ) + rows = list(db.scalars(stmt).all()) + fired = 0 + + for row in rows: + if row.last_fired_at and row.last_fired_at >= row.due_at: + continue + + local_when = _format_local(row.due_at, row.timezone, all_day=row.all_day) + notice = f"📅 **Напоминание** · {row.title}\n\n_{local_when}_" + if row.notes: + notice += f"\n{row.notes}" + + post_notice_to_latest_chat(notice) + row.last_fired_at = now + + if row.recurrence == RECURRENCE_NONE: + row.completed_at = now + row.enabled = False + else: + row.due_at = _advance_due(row.due_at, row.recurrence) + row.last_fired_at = None + + row.updated_at = now + fired += 1 + + if fired: + db.commit() + bump_notify_seq(db) + logger.info("Reminders fired: %d", fired) + + return fired diff --git a/backend/app/reminders/notify.py b/backend/app/reminders/notify.py new file mode 100644 index 0000000..85a9a7c --- /dev/null +++ b/backend/app/reminders/notify.py @@ -0,0 +1,19 @@ +from sqlalchemy.orm import Session + +from app.homelab.state import get_state, set_state + +NOTIFY_SEQ_KEY = "reminders_notify_seq" + + +def get_notify_seq(db: Session) -> int: + raw = get_state(db, NOTIFY_SEQ_KEY) + try: + return int(raw or 0) + except ValueError: + return 0 + + +def bump_notify_seq(db: Session) -> int: + seq = get_notify_seq(db) + 1 + set_state(db, NOTIFY_SEQ_KEY, str(seq)) + return seq diff --git a/backend/app/reminders/service.py b/backend/app/reminders/service.py new file mode 100644 index 0000000..aad231a --- /dev/null +++ b/backend/app/reminders/service.py @@ -0,0 +1,226 @@ +import calendar +from datetime import datetime, timedelta, timezone +from typing import Any +from zoneinfo import ZoneInfo + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.models import Reminder +from app.homelab.context import resolve_timezone +from app.reminders.notify import bump_notify_seq, get_notify_seq + +RECURRENCE_NONE = "none" +RECURRENCE_DAILY = "daily" +RECURRENCE_WEEKLY = "weekly" +RECURRENCE_MONTHLY = "monthly" +VALID_RECURRENCE = frozenset({RECURRENCE_NONE, RECURRENCE_DAILY, RECURRENCE_WEEKLY, RECURRENCE_MONTHLY}) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_due_at(raw: str, tz_name: str) -> datetime: + clean = raw.strip() + if not clean: + raise ValueError("due_at не может быть пустым") + try: + dt = datetime.fromisoformat(clean.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"Неверный формат даты: {raw}") from exc + if dt.tzinfo is None: + try: + dt = dt.replace(tzinfo=ZoneInfo(tz_name)) + except Exception: + dt = dt.replace(tzinfo=ZoneInfo("Europe/Moscow")) + return dt.astimezone(timezone.utc) + + +def _advance_due(due_at: datetime, recurrence: str) -> datetime: + if recurrence == RECURRENCE_DAILY: + return due_at + timedelta(days=1) + if recurrence == RECURRENCE_WEEKLY: + return due_at + timedelta(weeks=1) + if recurrence == RECURRENCE_MONTHLY: + month = due_at.month + 1 + year = due_at.year + if month > 12: + month = 1 + year += 1 + day = min(due_at.day, calendar.monthrange(year, month)[1]) + return due_at.replace(year=year, month=month, day=day) + return due_at + + +def _format_local(dt: datetime, tz_name: str, *, all_day: bool = False) -> str: + try: + local = dt.astimezone(ZoneInfo(tz_name)) + except Exception: + local = dt.astimezone(ZoneInfo("Europe/Moscow")) + if all_day: + return local.strftime("%Y-%m-%d") + return local.strftime("%Y-%m-%d %H:%M") + + +class RemindersService: + def __init__(self, db: Session): + self.db = db + + def _tz(self) -> str: + return resolve_timezone(self.db) + + def _to_dict(self, row: Reminder) -> dict[str, Any]: + tz = row.timezone or self._tz() + return { + "id": row.id, + "title": row.title, + "notes": row.notes, + "due_at": row.due_at.isoformat(), + "due_at_local": _format_local(row.due_at, tz, all_day=row.all_day), + "all_day": row.all_day, + "recurrence": row.recurrence, + "enabled": row.enabled, + "completed_at": row.completed_at.isoformat() if row.completed_at else None, + "timezone": tz, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + def snapshot(self) -> dict[str, Any]: + upcoming = self.list_upcoming(limit=12) + return { + "notify_seq": get_notify_seq(self.db), + "upcoming": upcoming, + "upcoming_count": len(upcoming), + "timezone": self._tz(), + } + + def list_upcoming(self, *, limit: int = 30) -> list[dict[str, Any]]: + stmt = ( + select(Reminder) + .where( + Reminder.enabled.is_(True), + Reminder.completed_at.is_(None), + ) + .order_by(Reminder.due_at.asc()) + .limit(limit) + ) + return [self._to_dict(row) for row in self.db.scalars(stmt).all()] + + def list_in_range( + self, + *, + date_from: datetime, + date_to: datetime, + ) -> list[dict[str, Any]]: + stmt = ( + select(Reminder) + .where( + Reminder.enabled.is_(True), + Reminder.completed_at.is_(None), + Reminder.due_at >= date_from, + Reminder.due_at < date_to, + ) + .order_by(Reminder.due_at.asc()) + ) + return [self._to_dict(row) for row in self.db.scalars(stmt).all()] + + def get(self, reminder_id: int) -> dict[str, Any] | None: + row = self.db.get(Reminder, reminder_id) + return self._to_dict(row) if row else None + + def create( + self, + *, + title: str, + due_at: str, + notes: str = "", + all_day: bool = False, + recurrence: str = RECURRENCE_NONE, + ) -> dict[str, Any]: + clean_title = title.strip() + if not clean_title: + raise ValueError("Название напоминания не может быть пустым") + rec = (recurrence or RECURRENCE_NONE).strip().lower() + if rec not in VALID_RECURRENCE: + raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") + + tz = self._tz() + due = _parse_due_at(due_at, tz) + row = Reminder( + title=clean_title, + notes=notes.strip(), + due_at=due, + all_day=all_day, + recurrence=rec, + timezone=tz, + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + bump_notify_seq(self.db) + return {"ok": True, "reminder": self._to_dict(row), "created": True} + + def update( + self, + reminder_id: int, + *, + title: str | None = None, + due_at: str | None = None, + notes: str | None = None, + all_day: bool | None = None, + recurrence: str | None = None, + enabled: bool | None = None, + ) -> dict[str, Any]: + row = self.db.get(Reminder, reminder_id) + if not row: + raise ValueError("Напоминание не найдено") + + if title is not None: + clean = title.strip() + if not clean: + raise ValueError("Название не может быть пустым") + row.title = clean + if notes is not None: + row.notes = notes.strip() + if due_at is not None: + row.due_at = _parse_due_at(due_at, row.timezone or self._tz()) + row.last_fired_at = None + if all_day is not None: + row.all_day = all_day + if recurrence is not None: + rec = recurrence.strip().lower() + if rec not in VALID_RECURRENCE: + raise ValueError(f"recurrence должен быть один из: {', '.join(sorted(VALID_RECURRENCE))}") + row.recurrence = rec + if enabled is not None: + row.enabled = enabled + + row.updated_at = _utcnow() + self.db.commit() + self.db.refresh(row) + bump_notify_seq(self.db) + return {"ok": True, "reminder": self._to_dict(row)} + + def delete(self, reminder_id: int) -> dict[str, Any]: + row = self.db.get(Reminder, reminder_id) + if not row: + raise ValueError("Напоминание не найдено") + title = row.title + self.db.delete(row) + self.db.commit() + bump_notify_seq(self.db) + return {"ok": True, "deleted_id": reminder_id, "title": title} + + def complete(self, reminder_id: int) -> dict[str, Any]: + row = self.db.get(Reminder, reminder_id) + if not row: + raise ValueError("Напоминание не найдено") + now = _utcnow() + row.completed_at = now + row.enabled = False + row.updated_at = now + self.db.commit() + self.db.refresh(row) + bump_notify_seq(self.db) + return {"ok": True, "reminder": self._to_dict(row)} diff --git a/backend/app/reminders/watcher.py b/backend/app/reminders/watcher.py new file mode 100644 index 0000000..6129746 --- /dev/null +++ b/backend/app/reminders/watcher.py @@ -0,0 +1,31 @@ +import asyncio +import logging + +from app.config import get_settings +from app.db.base import SessionLocal +from app.reminders.fire import check_due_reminders + +logger = logging.getLogger(__name__) + +WATCH_INTERVAL_SEC = 30 + + +async def reminders_watcher_loop() -> None: + while True: + try: + await asyncio.sleep(WATCH_INTERVAL_SEC) + if not get_settings().reminders_enabled: + continue + await _tick() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Reminders watcher error") + + +async def _tick() -> None: + db = SessionLocal() + try: + check_due_reminders(db) + finally: + db.close() diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 82c55da..d22c45d 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.reminders.service import RemindersService from app.shopping.service import ShoppingService TOOL_DEFINITIONS: list[dict[str, Any]] = [ @@ -585,6 +586,89 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ }, }, }, + { + "type": "function", + "function": { + "name": "list_reminders", + "description": "Список активных напоминаний. «Что напомнил», «мои напоминания».", + "parameters": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Макс. записей, по умолчанию 20"}, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "create_reminder", + "description": ( + "Создать напоминание. due_at — ISO datetime в часовом поясе пользователя " + "(см. [Текущее время]). Примеры: через 15 мин, завтра 09:00, 2027-05-12T12:16:00." + ), + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "О чём напомнить"}, + "due_at": {"type": "string", "description": "ISO datetime"}, + "notes": {"type": "string"}, + "all_day": {"type": "boolean"}, + "recurrence": { + "type": "string", + "enum": ["none", "daily", "weekly", "monthly"], + "description": "Повтор", + }, + }, + "required": ["title", "due_at"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "update_reminder", + "description": "Изменить напоминание по id.", + "parameters": { + "type": "object", + "properties": { + "reminder_id": {"type": "integer"}, + "title": {"type": "string"}, + "due_at": {"type": "string"}, + "notes": {"type": "string"}, + "all_day": {"type": "boolean"}, + "recurrence": {"type": "string", "enum": ["none", "daily", "weekly", "monthly"]}, + "enabled": {"type": "boolean"}, + }, + "required": ["reminder_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "delete_reminder", + "description": "Удалить напоминание по id.", + "parameters": { + "type": "object", + "properties": {"reminder_id": {"type": "integer"}}, + "required": ["reminder_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "complete_reminder", + "description": "Отметить напоминание выполненным (снять с календаря).", + "parameters": { + "type": "object", + "properties": {"reminder_id": {"type": "integer"}}, + "required": ["reminder_id"], + }, + }, + }, { "type": "function", "function": { @@ -618,6 +702,7 @@ async def execute_tool( memory = MemoryService(db) fitness = FitnessService(db) shopping = ShoppingService(db) + reminders = RemindersService(db) try: if name == "get_pomodoro_status": @@ -792,6 +877,30 @@ async def execute_tool( result = shopping.remove_item(int(arguments["item_id"])) elif name == "delete_shopping_list": result = shopping.delete_list(int(arguments["list_id"])) + elif name == "list_reminders": + result = reminders.list_upcoming(limit=int(arguments.get("limit") or 20)) + elif name == "create_reminder": + result = reminders.create( + title=arguments.get("title", ""), + due_at=arguments.get("due_at", ""), + notes=arguments.get("notes", ""), + all_day=bool(arguments.get("all_day", False)), + recurrence=arguments.get("recurrence", "none"), + ) + elif name == "update_reminder": + result = reminders.update( + int(arguments["reminder_id"]), + title=arguments.get("title"), + due_at=arguments.get("due_at"), + notes=arguments.get("notes"), + all_day=arguments.get("all_day"), + recurrence=arguments.get("recurrence"), + enabled=arguments.get("enabled"), + ) + elif name == "delete_reminder": + result = reminders.delete(int(arguments["reminder_id"])) + elif name == "complete_reminder": + result = reminders.complete(int(arguments["reminder_id"])) else: return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4ca5794..ef2b1c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { useVisualViewportHeight } from "./hooks/useVisualViewport"; import Character from "./pages/Character"; import Chat from "./pages/Chat"; import Fitness from "./pages/Fitness"; +import Reminders from "./pages/Reminders"; import Shopping from "./pages/Shopping"; import Memory from "./pages/Memory"; import Pomodoro from "./pages/Pomodoro"; @@ -27,6 +28,7 @@ export default function App() { Память Фитнес Покупки + Календарь @@ -38,6 +40,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0299b26..f7c11ff 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -422,6 +422,33 @@ export const api = { request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, { method: "POST", }), + + getRemindersSnapshot: () => request("/api/v1/reminders"), + + getRemindersCalendar: (year: number, month: number) => + request(`/api/v1/reminders/calendar?year=${year}&month=${month}`), + + createReminder: (payload: ReminderCreatePayload) => + request<{ ok: boolean; reminder: Reminder }>("/api/v1/reminders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + + updateReminder: (id: number, payload: Partial & { enabled?: boolean }) => + request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + + deleteReminder: (id: number) => + request<{ ok: boolean }>(`/api/v1/reminders/${id}`, { method: "DELETE" }), + + completeReminder: (id: number) => + request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}/complete`, { + method: "POST", + }), }; export interface ShoppingListItem { @@ -449,3 +476,39 @@ export interface ShoppingSnapshot { total_items: number; unchecked_items: number; } + +export interface Reminder { + id: number; + title: string; + notes: string; + due_at: string; + due_at_local: string; + all_day: boolean; + recurrence: string; + enabled: boolean; + completed_at: string | null; + timezone: string; + created_at: string | null; +} + +export interface RemindersSnapshot { + notify_seq: number; + upcoming: Reminder[]; + upcoming_count: number; + timezone: string; +} + +export interface RemindersCalendar { + year: number; + month: number; + timezone: string; + reminders: Reminder[]; +} + +export interface ReminderCreatePayload { + title: string; + due_at: string; + notes?: string; + all_day?: boolean; + recurrence?: string; +} diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index f5376a9..c9c16bf 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -22,6 +22,7 @@ function noticeLabel(content: string): string { if (content.startsWith("🎨")) return "картинка"; if (content.startsWith("⚠️")) return "сервер"; if (content.startsWith("🛒")) return "покупки"; + if (content.startsWith("📅")) return "напоминание"; return "система"; } @@ -54,6 +55,8 @@ export default function Chat() { const scrollRafRef = useRef(null); const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro(); const [lastNotifySeq, setLastNotifySeq] = useState(0); + const lastReminderNotifySeq = useRef(0); + const remindersNotifyReady = useRef(false); const pendingHistoryReload = useRef(false); const loadSessions = async () => { @@ -134,6 +137,41 @@ export default function Chat() { } }, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro, loading]); + useEffect(() => { + let cancelled = false; + + const poll = async () => { + try { + const data = await api.getRemindersSnapshot(); + if (cancelled) return; + if (!remindersNotifyReady.current) { + remindersNotifyReady.current = true; + lastReminderNotifySeq.current = data.notify_seq; + return; + } + if (data.notify_seq > lastReminderNotifySeq.current) { + lastReminderNotifySeq.current = data.notify_seq; + if (activeId) { + if (loading) { + pendingHistoryReload.current = true; + } else { + loadMessages(activeId).catch(console.error); + } + } + } + } catch { + // ignore polling errors + } + }; + + poll().catch(console.error); + const id = setInterval(() => poll().catch(console.error), 60000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [activeId, loading]); + const handleNewChat = async () => { const session = await api.createSession(); await loadSessions(); diff --git a/frontend/src/pages/Reminders.css b/frontend/src/pages/Reminders.css new file mode 100644 index 0000000..e7f563e --- /dev/null +++ b/frontend/src/pages/Reminders.css @@ -0,0 +1,193 @@ +.reminders-page { + display: grid; + grid-template-columns: 1fr 320px; + gap: 1rem; + height: 100%; + min-height: 0; + overflow: auto; + padding: 1rem; +} + +.reminders-calendar-card, +.reminders-sidebar { + background: #12151c; + border: 1px solid #2f3748; + border-radius: 12px; + padding: 1rem; + overflow: auto; +} + +.reminders-calendar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.reminders-calendar-header h2 { + margin: 0; + font-size: 1.2rem; +} + +.reminders-calendar-header button { + width: 2rem; + height: 2rem; + border-radius: 8px; + border: 1px solid #2f3748; + background: #1a1f2b; + color: inherit; + cursor: pointer; +} + +.reminders-tz { + margin: 0 0 0.75rem; + color: #8b95a5; + font-size: 0.85rem; +} + +.reminders-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.25rem; + margin-bottom: 0.35rem; + color: #8b95a5; + font-size: 0.8rem; + text-align: center; +} + +.reminders-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.35rem; +} + +.reminders-day { + position: relative; + min-height: 3.2rem; + border: 1px solid #2f3748; + border-radius: 8px; + background: #1a1f2b; + color: inherit; + cursor: pointer; + padding: 0.35rem; +} + +.reminders-day-empty { + border: none; + background: transparent; + cursor: default; +} + +.reminders-day.today { + border-color: #4a7cff; +} + +.reminders-day.selected { + background: #1c2740; + border-color: #6b93ff; +} + +.reminders-day-num { + font-size: 0.9rem; +} + +.reminders-day-badge { + position: absolute; + right: 0.35rem; + bottom: 0.35rem; + min-width: 1.1rem; + height: 1.1rem; + border-radius: 999px; + background: #4a7cff; + color: #fff; + font-size: 0.7rem; + line-height: 1.1rem; + text-align: center; +} + +.reminders-day-panel { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #2f3748; +} + +.reminders-day-panel ul, +.reminders-upcoming { + list-style: none; + margin: 0; + padding: 0; +} + +.reminders-day-panel li, +.reminders-upcoming li { + padding: 0.65rem 0; + border-bottom: 1px solid #2a3140; +} + +.reminders-upcoming-title { + font-weight: 600; +} + +.reminders-upcoming-meta { + color: #8b95a5; + font-size: 0.85rem; + margin-top: 0.15rem; +} + +.reminders-item-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.4rem; +} + +.reminders-item-actions button { + font-size: 0.8rem; + padding: 0.25rem 0.5rem; + border-radius: 6px; + border: 1px solid #2f3748; + background: #1a1f2b; + color: inherit; + cursor: pointer; +} + +.reminders-form { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin-bottom: 1.25rem; +} + +.reminders-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + color: #b8c0cc; +} + +.reminders-form input, +.reminders-form select, +.reminders-form textarea { + border-radius: 8px; + border: 1px solid #2f3748; + background: #1a1f2b; + color: inherit; + padding: 0.5rem 0.65rem; +} + +.reminders-muted { + color: #8b95a5; + font-size: 0.9rem; +} + +.reminders-error { + color: #ff8f8f; + margin-top: 0.75rem; +} + +@media (max-width: 900px) { + .reminders-page { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/pages/Reminders.tsx b/frontend/src/pages/Reminders.tsx new file mode 100644 index 0000000..52019e2 --- /dev/null +++ b/frontend/src/pages/Reminders.tsx @@ -0,0 +1,300 @@ +import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { api, Reminder } from "../api/client"; +import "./Reminders.css"; + +const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; +const MONTHS = [ + "Январь", + "Февраль", + "Март", + "Апрель", + "Май", + "Июнь", + "Июль", + "Август", + "Сентябрь", + "Октябрь", + "Ноябрь", + "Декабрь", +]; + +function pad2(n: number) { + return String(n).padStart(2, "0"); +} + +function dateKey(year: number, month: number, day: number) { + return `${year}-${pad2(month)}-${pad2(day)}`; +} + +function defaultDatetimeLocal() { + const d = new Date(); + d.setMinutes(d.getMinutes() + 30); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +function toIsoDueAt(localValue: string) { + const d = new Date(localValue); + const offset = -d.getTimezoneOffset(); + const sign = offset >= 0 ? "+" : "-"; + const abs = Math.abs(offset); + const hh = pad2(Math.floor(abs / 60)); + const mm = pad2(abs % 60); + return `${localValue}:00${sign}${hh}:${mm}`; +} + +export default function Reminders() { + const now = new Date(); + const [year, setYear] = useState(now.getFullYear()); + const [month, setMonth] = useState(now.getMonth() + 1); + const [reminders, setReminders] = useState([]); + const [upcoming, setUpcoming] = useState([]); + const [timezone, setTimezone] = useState(""); + const [selectedDay, setSelectedDay] = useState(null); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(false); + + const [title, setTitle] = useState(""); + const [dueLocal, setDueLocal] = useState(defaultDatetimeLocal); + const [notes, setNotes] = useState(""); + const [recurrence, setRecurrence] = useState("none"); + + const load = useCallback(async () => { + setLoading(true); + try { + const [calendar, snapshot] = await Promise.all([ + api.getRemindersCalendar(year, month), + api.getRemindersSnapshot(), + ]); + setReminders(calendar.reminders); + setTimezone(calendar.timezone || snapshot.timezone); + setUpcoming(snapshot.upcoming); + setMessage(""); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка загрузки"); + } finally { + setLoading(false); + } + }, [year, month]); + + useEffect(() => { + load().catch(console.error); + }, [load]); + + const byDay = useMemo(() => { + const map = new Map(); + for (const item of reminders) { + const key = item.due_at_local.slice(0, 10); + const list = map.get(key) ?? []; + list.push(item); + map.set(key, list); + } + return map; + }, [reminders]); + + const monthCells = useMemo(() => { + const first = new Date(year, month - 1, 1); + const daysInMonth = new Date(year, month, 0).getDate(); + const startPad = (first.getDay() + 6) % 7; + const cells: Array<{ day: number | null; key: string | null }> = []; + for (let i = 0; i < startPad; i++) cells.push({ day: null, key: null }); + for (let day = 1; day <= daysInMonth; day++) { + cells.push({ day, key: dateKey(year, month, day) }); + } + return cells; + }, [year, month]); + + const selectedReminders = selectedDay ? byDay.get(selectedDay) ?? [] : []; + + const shiftMonth = (delta: number) => { + let m = month + delta; + let y = year; + if (m < 1) { + m = 12; + y -= 1; + } else if (m > 12) { + m = 1; + y += 1; + } + setMonth(m); + setYear(y); + setSelectedDay(null); + }; + + const handleCreate = async (e: FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + try { + await api.createReminder({ + title: title.trim(), + due_at: toIsoDueAt(dueLocal), + notes: notes.trim(), + recurrence, + }); + setTitle(""); + setNotes(""); + setRecurrence("none"); + setDueLocal(defaultDatetimeLocal()); + await load(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Ошибка"); + } + }; + + const handleComplete = async (id: number) => { + await api.completeReminder(id); + await load(); + }; + + const handleDelete = async (id: number) => { + await api.deleteReminder(id); + await load(); + }; + + const todayKey = dateKey(now.getFullYear(), now.getMonth() + 1, now.getDate()); + + return ( +
+
+
+ +

+ {MONTHS[month - 1]} {year} +

+ +
+ {timezone &&

Часовой пояс: {timezone}

} + +
+ {WEEKDAYS.map((d) => ( + {d} + ))} +
+ +
+ {monthCells.map((cell, idx) => { + if (!cell.day || !cell.key) { + return
; + } + const count = byDay.get(cell.key)?.length ?? 0; + const isToday = cell.key === todayKey; + const isSelected = cell.key === selectedDay; + return ( + + ); + })} +
+ + {selectedDay && ( +
+

{selectedDay}

+ {selectedReminders.length === 0 ? ( +

Нет напоминаний

+ ) : ( +
    + {selectedReminders.map((r) => ( +
  • + {r.title} + {r.all_day ? "весь день" : r.due_at_local.slice(11)} + {r.notes &&

    {r.notes}

    } +
    + + +
    +
  • + ))} +
+ )} +
+ )} +
+ +