diff --git a/backend/app/character/card.py b/backend/app/character/card.py
index 4254e44..6b62345 100644
--- a/backend/app/character/card.py
+++ b/backend/app/character/card.py
@@ -27,7 +27,8 @@ TOOLS_INSTRUCTIONS = """
- Покупки: 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 (часовой пояс из [Текущее время]).
+- «Напомни через 15 минут», «завтра утром», «12 мая в 9:00» → create_reminder с due_at в ISO (часовой пояс из [Текущее время]).
+- День рождения, Новый год и другие праздники → recurrence yearly.
- Относительное время считай от «Сейчас» в контексте. «Утром» ≈ 09:00, «вечером» ≈ 19:00, если не уточнено иначе.
""".strip()
diff --git a/backend/app/reminders/service.py b/backend/app/reminders/service.py
index aad231a..1fccd9c 100644
--- a/backend/app/reminders/service.py
+++ b/backend/app/reminders/service.py
@@ -14,7 +14,14 @@ RECURRENCE_NONE = "none"
RECURRENCE_DAILY = "daily"
RECURRENCE_WEEKLY = "weekly"
RECURRENCE_MONTHLY = "monthly"
-VALID_RECURRENCE = frozenset({RECURRENCE_NONE, RECURRENCE_DAILY, RECURRENCE_WEEKLY, RECURRENCE_MONTHLY})
+RECURRENCE_YEARLY = "yearly"
+VALID_RECURRENCE = frozenset({
+ RECURRENCE_NONE,
+ RECURRENCE_DAILY,
+ RECURRENCE_WEEKLY,
+ RECURRENCE_MONTHLY,
+ RECURRENCE_YEARLY,
+})
def _utcnow() -> datetime:
@@ -50,6 +57,10 @@ def _advance_due(due_at: datetime, recurrence: str) -> datetime:
year += 1
day = min(due_at.day, calendar.monthrange(year, month)[1])
return due_at.replace(year=year, month=month, day=day)
+ if recurrence == RECURRENCE_YEARLY:
+ year = due_at.year + 1
+ day = min(due_at.day, calendar.monthrange(year, due_at.month)[1])
+ return due_at.replace(year=year, day=day)
return due_at
diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py
index 855df7e..9b83953 100644
--- a/backend/app/tools/registry.py
+++ b/backend/app/tools/registry.py
@@ -649,8 +649,8 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"all_day": {"type": "boolean"},
"recurrence": {
"type": "string",
- "enum": ["none", "daily", "weekly", "monthly"],
- "description": "Повтор",
+ "enum": ["none", "daily", "weekly", "monthly", "yearly"],
+ "description": "Повтор (yearly — день рождения, Новый год)",
},
},
"required": ["title", "due_at"],
@@ -670,7 +670,10 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"due_at": {"type": "string"},
"notes": {"type": "string"},
"all_day": {"type": "boolean"},
- "recurrence": {"type": "string", "enum": ["none", "daily", "weekly", "monthly"]},
+ "recurrence": {
+ "type": "string",
+ "enum": ["none", "daily", "weekly", "monthly", "yearly"],
+ },
"enabled": {"type": "boolean"},
},
"required": ["reminder_id"],
diff --git a/backend/scripts/seed_holiday_reminders.py b/backend/scripts/seed_holiday_reminders.py
new file mode 100644
index 0000000..05ab0a5
--- /dev/null
+++ b/backend/scripts/seed_holiday_reminders.py
@@ -0,0 +1,52 @@
+"""Идемпотентно добавляет ежегодные напоминания: ДР 12 мая и Новый год."""
+
+from sqlalchemy import select
+
+from app.db.base import SessionLocal
+from app.db.models import Reminder
+from app.reminders.service import RECURRENCE_YEARLY, RemindersService
+
+DEFAULTS = (
+ {
+ "title": "День рождения",
+ "due_at": "2027-05-12T09:00:00+03:00",
+ "notes": "С днём рождения!",
+ "all_day": True,
+ "recurrence": RECURRENCE_YEARLY,
+ },
+ {
+ "title": "Новый год",
+ "due_at": "2026-12-31T23:55:00+03:00",
+ "notes": "С наступающим Новым годом!",
+ "all_day": False,
+ "recurrence": RECURRENCE_YEARLY,
+ },
+)
+
+
+def main() -> None:
+ db = SessionLocal()
+ try:
+ service = RemindersService(db)
+ for item in DEFAULTS:
+ exists = db.scalar(
+ select(Reminder.id)
+ .where(
+ Reminder.title == item["title"],
+ Reminder.recurrence == RECURRENCE_YEARLY,
+ Reminder.enabled.is_(True),
+ )
+ .limit(1)
+ )
+ if exists:
+ print(f"skip: {item['title']} (id={exists})")
+ continue
+ result = service.create(**item)
+ reminder = result["reminder"]
+ print(f"created: {reminder['title']} · {reminder['due_at_local']} · yearly")
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index f647f0f..10b23be 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -9,9 +9,13 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s;
+ proxy_read_timeout 300s;
}
location / {
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index db1255f..c9ce3f8 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -271,22 +271,35 @@ export const api = {
}
};
- while (true) {
- const { done, value } = await reader.read();
- if (value) {
- buffer += decoder.decode(value, { stream: !done });
- }
-
- const parts = buffer.split("\n\n");
- buffer = parts.pop() ?? "";
- yield* flushParts(parts);
-
- if (done) {
- if (buffer.trim()) {
- yield* flushParts([buffer]);
+ try {
+ while (true) {
+ let done = false;
+ let value: Uint8Array | undefined;
+ try {
+ ({ done, value } = await reader.read());
+ } catch {
+ throw new Error(
+ "Соединение прервалось (таймаут прокси). Обновите чат — ответ мог уже сохраниться.",
+ );
+ }
+
+ if (value) {
+ buffer += decoder.decode(value, { stream: !done });
+ }
+
+ const parts = buffer.split("\n\n");
+ buffer = parts.pop() ?? "";
+ yield* flushParts(parts);
+
+ if (done) {
+ if (buffer.trim()) {
+ yield* flushParts([buffer]);
+ }
+ break;
}
- break;
}
+ } finally {
+ reader.releaseLock();
}
},
diff --git a/frontend/src/pages/Reminders.tsx b/frontend/src/pages/Reminders.tsx
index 52019e2..56963a6 100644
--- a/frontend/src/pages/Reminders.tsx
+++ b/frontend/src/pages/Reminders.tsx
@@ -252,6 +252,7 @@ export default function Reminders() {
+