added RAG, Multiuser, TG bot

This commit is contained in:
2026-06-14 06:26:16 +00:00
parent c8a9429bed
commit 0c8ab6018a
24 changed files with 1280 additions and 479 deletions
+11
View File
@@ -118,6 +118,17 @@ def get_history(
return FitnessService(db, user.id).get_history(days=days, end_day=end_day)
@router.get("/fitness/charts")
def get_charts(
weeks: int = 52,
trend: bool = True,
end: str | None = None,
db: Session = Depends(get_db), user: User = Depends(get_current_user),
) -> dict[str, Any]:
end_day = date.fromisoformat(end) if end else None
return FitnessService(db, user.id).get_charts(weeks=weeks, trend=trend, end_day=end_day)
@router.get("/fitness/profile")
def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]:
profile = FitnessService(db, user.id).get_profile()
+1 -1
View File
@@ -23,7 +23,7 @@ TOOLS_INSTRUCTIONS = """
- В текстовых ответах пользователю не используй эмодзи.
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй.
- Картинки: generate_image — «нарисуй себя» → draw_self=true (портрет по appearance_tags, без LLM); иначе 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.
+13 -2
View File
@@ -16,6 +16,18 @@ def _format_time(seconds: int) -> str:
return f"{minutes:02d}:{secs:02d}"
def _format_image_generation_notice(data: dict[str, Any]) -> str:
url = data.get("url", "")
positive = (data.get("prompt") or "").strip()
negative = (data.get("negative_prompt") or "").strip()
lines = ["🎨 **Картинка готова**", "", f"![image]({url})"]
if positive:
lines.extend(["", "**Comfy (+):**", f"```\n{positive}\n```"])
if negative:
lines.extend(["", "**Comfy ():**", f"```\n{negative}\n```"])
return "\n".join(lines)
def format_phase_completed_notice(
session: PomodoroSession,
next_phase: str | None,
@@ -253,8 +265,7 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
return f"💪 **Напоминание {r.get('kind')}** · {state}"
if tool_name == "generate_image" and data.get("ok"):
url = data.get("url", "")
return f"🎨 **Картинка готова**\n\n![image]({url})"
return _format_image_generation_notice(data)
if tool_name == "create_shopping_list" and data.get("ok"):
lst = data.get("list") or {}
+355
View File
@@ -0,0 +1,355 @@
"""Weekly fitness chart data and least-squares trend lines."""
from __future__ import annotations
from collections import defaultdict
from datetime import date, datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.models import BodyMetric, FoodLog, StepLog, WaterLog
METRIC_DEFS: dict[str, dict[str, str]] = {
"weight_kg": {"label": "Вес", "unit": "кг"},
"body_fat_pct": {"label": "Жир", "unit": "%"},
"calories": {"label": "Калории", "unit": "ккал/день"},
"protein_g": {"label": "Белок", "unit": "г/день"},
"water_l": {"label": "Вода", "unit": "л/день"},
"steps": {"label": "Шаги", "unit": "шаг/день"},
}
def week_start(day: date) -> date:
return day - timedelta(days=day.weekday())
def linear_regression(points: list[tuple[float, float]]) -> dict[str, float] | None:
"""Ordinary least squares y = slope * x + intercept."""
n = len(points)
if n < 2:
return None
sum_x = sum(x for x, _ in points)
sum_y = sum(y for _, y in points)
sum_xx = sum(x * x for x, _ in points)
sum_xy = sum(x * y for x, y in points)
denom = n * sum_xx - sum_x * sum_x
if abs(denom) < 1e-12:
return None
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n
return {"slope": slope, "intercept": intercept}
def _avg(values: list[float]) -> float | None:
if not values:
return None
return sum(values) / len(values)
def _last(values: list[tuple[date, float]]) -> float | None:
if not values:
return None
values.sort(key=lambda item: item[0])
return values[-1][1]
def build_fitness_charts(
db: Session,
user_id: int,
*,
weeks: int = 52,
trend: bool = True,
end_day: date | None = None,
) -> dict[str, Any]:
weeks = max(4, min(int(weeks), 52))
end = end_day or datetime.now(timezone.utc).date()
last_week_start = week_start(end)
first_week_start = last_week_start - timedelta(weeks=weeks - 1)
range_start = datetime.combine(first_week_start, datetime.min.time(), tzinfo=timezone.utc)
range_end = datetime.combine(end, datetime.max.time(), tzinfo=timezone.utc)
daily: dict[date, dict[str, float]] = defaultdict(lambda: {
"calories": 0.0,
"protein_g": 0.0,
"fat_g": 0.0,
"carbs_g": 0.0,
"water_ml": 0.0,
"steps": 0.0,
})
daily_flags: dict[date, set[str]] = defaultdict(set)
foods = db.scalars(
select(FoodLog).where(
FoodLog.user_id == user_id,
FoodLog.logged_at >= range_start,
FoodLog.logged_at <= range_end,
)
).all()
for row in foods:
d = row.logged_at.date()
daily[d]["calories"] += row.calories
daily[d]["protein_g"] += row.protein_g
daily[d]["fat_g"] += row.fat_g
daily[d]["carbs_g"] += row.carbs_g
daily_flags[d].add("nutrition")
waters = db.scalars(
select(WaterLog).where(
WaterLog.user_id == user_id,
WaterLog.logged_at >= range_start,
WaterLog.logged_at <= range_end,
)
).all()
for row in waters:
d = row.logged_at.date()
daily[d]["water_ml"] += float(row.amount_ml)
daily_flags[d].add("water")
steps_rows = db.scalars(
select(StepLog).where(
StepLog.user_id == user_id,
StepLog.logged_at >= range_start,
StepLog.logged_at <= range_end,
)
).all()
for row in steps_rows:
d = row.logged_at.date()
daily[d]["steps"] += float(row.steps)
daily_flags[d].add("steps")
body_rows = db.scalars(
select(BodyMetric).where(
BodyMetric.user_id == user_id,
BodyMetric.recorded_at >= range_start,
BodyMetric.recorded_at <= range_end,
)
).all()
body_by_day: dict[date, list[tuple[date, float, float | None]]] = defaultdict(list)
for row in body_rows:
d = row.recorded_at.date()
body_by_day[d].append((d, row.weight_kg, row.body_fat_pct))
daily_flags[d].add("body")
week_slots: list[dict[str, Any]] = []
cursor = first_week_start
while cursor <= last_week_start:
week_slots.append(
{
"week_start": cursor.isoformat(),
"week_end": (cursor + timedelta(days=6)).isoformat(),
}
)
cursor += timedelta(weeks=1)
days_with_data = len(daily_flags)
weeks_with_data = 0
def rollup_week(metric: str) -> list[dict[str, Any]]:
nonlocal weeks_with_data
points: list[dict[str, Any]] = []
local_weeks_with_data = 0
for idx, slot in enumerate(week_slots):
ws = date.fromisoformat(slot["week_start"])
we = date.fromisoformat(slot["week_end"])
day_cursor = ws
week_daily_values: list[float] = []
week_body_weight: list[tuple[date, float]] = []
week_body_fat: list[tuple[date, float]] = []
while day_cursor <= we:
if day_cursor > end:
break
flags = daily_flags.get(day_cursor, set())
totals = daily.get(day_cursor)
if metric == "weight_kg":
for _, w, _ in body_by_day.get(day_cursor, []):
week_body_weight.append((day_cursor, w))
elif metric == "body_fat_pct":
for _, _, bf in body_by_day.get(day_cursor, []):
if bf is not None:
week_body_fat.append((day_cursor, bf))
elif metric == "calories" and totals and "nutrition" in flags:
week_daily_values.append(totals["calories"])
elif metric == "protein_g" and totals and "nutrition" in flags:
week_daily_values.append(totals["protein_g"])
elif metric == "water_l" and totals and "water" in flags:
week_daily_values.append(totals["water_ml"] / 1000.0)
elif metric == "steps" and totals and "steps" in flags:
week_daily_values.append(totals["steps"])
day_cursor += timedelta(days=1)
value: float | None
days_in_week = 0
if metric == "weight_kg":
value = _last(week_body_weight)
days_in_week = len(week_body_weight)
elif metric == "body_fat_pct":
value = _last(week_body_fat)
days_in_week = len(week_body_fat)
else:
value = _avg(week_daily_values)
days_in_week = len(week_daily_values)
has_data = value is not None
if has_data:
local_weeks_with_data += 1
points.append(
{
"index": idx,
"week_start": slot["week_start"],
"week_end": slot["week_end"],
"value": round(value, 2) if value is not None else None,
"days_with_data": days_in_week,
"has_data": has_data,
}
)
weeks_with_data = max(weeks_with_data, local_weeks_with_data)
return points
series: dict[str, Any] = {}
for key, meta in METRIC_DEFS.items():
points = rollup_week(key)
reg_points = [(float(p["index"]), float(p["value"])) for p in points if p["has_data"] and p["value"] is not None]
trend_payload: dict[str, Any] | None = None
if trend and len(reg_points) >= 2:
fit = linear_regression(reg_points)
if fit:
line = [
{
"index": p["index"],
"week_start": p["week_start"],
"value": round(fit["slope"] * p["index"] + fit["intercept"], 2),
}
for p in points
]
trend_payload = {
"slope_per_week": round(fit["slope"], 4),
"intercept": round(fit["intercept"], 2),
"points_with_data": len(reg_points),
"line": line,
}
series[key] = {
"key": key,
"label": meta["label"],
"unit": meta["unit"],
"points": points,
"trend": trend_payload,
"data_points": sum(1 for p in points if p["has_data"]),
}
use_daily = days_with_data > 0 and days_with_data <= 14 and weeks_with_data <= 2
daily_series: dict[str, Any] | None = None
if use_daily:
daily_series = _build_daily_series(
daily,
daily_flags,
body_by_day,
end,
trend=trend,
lookback_days=min(30, max(days_with_data, 7)),
)
return {
"end_date": end.isoformat(),
"weeks": weeks,
"granularity": "day" if use_daily else "week",
"first_week_start": first_week_start.isoformat(),
"last_week_start": last_week_start.isoformat(),
"days_with_data": days_with_data,
"weeks_with_data": weeks_with_data,
"series": series,
"daily_series": daily_series,
}
def _build_daily_series(
daily: dict[date, dict[str, float]],
daily_flags: dict[date, set[str]],
body_by_day: dict[date, list[tuple[date, float, float | None]]],
end: date,
*,
trend: bool,
lookback_days: int,
) -> dict[str, Any]:
start = end - timedelta(days=lookback_days - 1)
day_points: list[date] = []
cursor = start
while cursor <= end:
day_points.append(cursor)
cursor += timedelta(days=1)
result: dict[str, Any] = {}
for key, meta in METRIC_DEFS.items():
points: list[dict[str, Any]] = []
for idx, d in enumerate(day_points):
value: float | None = None
has_data = False
if key == "weight_kg":
body = body_by_day.get(d, [])
pairs = [(x, w) for x, w, _ in body]
value = _last(pairs) if pairs else None
has_data = value is not None
elif key == "body_fat_pct":
fat_vals = [(x, bf) for x, _, bf in body_by_day.get(d, []) if bf is not None]
value = _last(fat_vals) if fat_vals else None
has_data = value is not None
else:
flags = daily_flags.get(d, set())
totals = daily.get(d)
if key == "calories" and totals and "nutrition" in flags:
value = totals["calories"]
has_data = True
elif key == "protein_g" and totals and "nutrition" in flags:
value = totals["protein_g"]
has_data = True
elif key == "water_l" and totals and "water" in flags:
value = totals["water_ml"] / 1000.0
has_data = True
elif key == "steps" and totals and "steps" in flags:
value = totals["steps"]
has_data = True
points.append(
{
"index": idx,
"date": d.isoformat(),
"value": round(value, 2) if value is not None else None,
"has_data": has_data,
}
)
reg_points = [(float(p["index"]), float(p["value"])) for p in points if p["has_data"] and p["value"] is not None]
trend_payload: dict[str, Any] | None = None
if trend and len(reg_points) >= 2:
fit = linear_regression(reg_points)
if fit:
trend_payload = {
"slope_per_day": round(fit["slope"], 4),
"intercept": round(fit["intercept"], 2),
"points_with_data": len(reg_points),
"line": [
{
"index": p["index"],
"date": p["date"],
"value": round(fit["slope"] * p["index"] + fit["intercept"], 2),
}
for p in points
],
}
result[key] = {
"key": key,
"label": meta["label"],
"unit": meta["unit"],
"points": points,
"trend": trend_payload,
"data_points": sum(1 for p in points if p["has_data"]),
}
return result
+17
View File
@@ -625,6 +625,23 @@ class FitnessService:
"reminders": self.list_reminders(),
}
def get_charts(
self,
*,
weeks: int = 52,
trend: bool = True,
end_day: date | None = None,
) -> dict[str, Any]:
from app.fitness.charts import build_fitness_charts
return build_fitness_charts(
self.db,
self.user_id,
weeks=weeks,
trend=trend,
end_day=end_day,
)
@staticmethod
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
return {
+100
View File
@@ -0,0 +1,100 @@
"""Сборка Anima-промптов без LLM (теги, без POV/hybrid)."""
from __future__ import annotations
import re
from dataclasses import dataclass
ANIMA_QUALITY = "masterpiece, best quality, score_7, anime"
ANIMA_NEGATIVE = "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
_JUNK_STANDALONE_TAGS = frozenset({
"white", "black", "skin", "ear", "ears", "girl", "boy", "fox", "wolf", "cat",
"short", "tall", "golden", "silver", "red", "blue", "green", "purple",
"pink", "brown", "eye", "eyes", "hair",
})
@dataclass(frozen=True)
class AnimaPromptBundle:
positive: str
negative: str
def _sanitize_tags(tag_str: str) -> str:
if not tag_str:
return ""
out: list[str] = []
seen: set[str] = set()
for raw in tag_str.split(","):
t = raw.strip()
if not t:
continue
key = t.lower().replace(" ", "_")
if key in seen or len(key) <= 2:
continue
if "_" not in key and key in _JUNK_STANDALONE_TAGS:
continue
seen.add(key)
out.append(t if "_" in t else key)
return ", ".join(out)
def _append_lora(parts: list[str], lora_name: str, lora_weight: float) -> None:
lora = (lora_name or "").strip()
if not lora:
return
weight = lora_weight if lora_weight > 0 else 0.8
parts.append(f"<lora:{lora}:{weight}>")
def build_draw_self_prompt(
appearance_tags: str,
*,
lora_name: str = "",
lora_weight: float = 0.8,
) -> AnimaPromptBundle:
"""Портрет «нарисуй себя» — только booru-теги, без POV и prose."""
appearance = _sanitize_tags(appearance_tags)
action = "looking_at_viewer, smile, upper_body, portrait"
environment = "simple_background, soft_lighting"
parts = [ANIMA_QUALITY]
if appearance:
parts.append(appearance)
parts.append(action)
parts.append(environment)
_append_lora(parts, lora_name, lora_weight)
positive = ", ".join(p.strip() for p in parts if p.strip())
return AnimaPromptBundle(positive=positive, negative=ANIMA_NEGATIVE)
def build_scene_tags_prompt(
scene_tags: str,
appearance_tags: str,
*,
lora_name: str = "",
lora_weight: float = 0.8,
) -> AnimaPromptBundle:
"""Прямая сцена из booru-тегов (без LLM)."""
appearance = _sanitize_tags(appearance_tags)
scene = _sanitize_tags(scene_tags)
parts = [ANIMA_QUALITY]
if appearance:
parts.append(appearance)
if scene:
parts.append(scene)
_append_lora(parts, lora_name, lora_weight)
positive = ", ".join(p.strip() for p in parts if p.strip())
return AnimaPromptBundle(positive=positive, negative=ANIMA_NEGATIVE)
def looks_like_booru_tags(text: str) -> bool:
"""Грубая эвристика: строка похожа на теги, а не на прозу."""
raw = (text or "").strip()
if not raw or len(raw) > 400:
return False
if raw.count(",") >= 2:
return True
return bool(re.search(r"\b\d+(girl|boy)s?\b", raw, re.I))
+1
View File
@@ -267,6 +267,7 @@ class ComfyUIClient:
"filename": filename,
"url": f"/api/v1/media/generated/{filename}",
"prompt": positive,
"negative_prompt": negative,
"backend": "anima" if _use_anima(self.settings) else "checkpoint",
}
+82 -23
View File
@@ -1,11 +1,10 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.character.service import CharacterService
from app.config import get_settings
from app.db.models import Message
from app.homelab.anima_prompt import AnimaPromptBundle, build_draw_self_prompt, build_scene_tags_prompt, looks_like_booru_tags
from app.homelab.comfyui import ComfyUIClient
from app.integrations.rp_chat import RpChatClient
@@ -17,6 +16,10 @@ def _card_image_settings(db: Session, user_id: int) -> dict[str, Any]:
def _session_messages(db: Session, session_id: int | None, limit: int = 8) -> list[dict[str, str]]:
if not session_id:
return []
from sqlalchemy import select
from app.db.models import Message
rows = db.scalars(
select(Message)
.where(
@@ -36,6 +39,43 @@ def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str:
return f"{positive} <lora:{lora_name}:{lora_weight}>"
async def _generate_from_bundle(
bundle: AnimaPromptBundle,
*,
backend: str,
persona_id: str = "",
) -> dict[str, Any]:
settings = get_settings()
if backend == "rp_chat":
client = RpChatClient()
gen_result = await client.generate(bundle.positive, bundle.negative)
if not gen_result.get("ok"):
return gen_result
saved = await client.save_image_locally(gen_result["image_path"])
if not saved.get("ok"):
return saved
return {
"ok": True,
"url": saved["url"],
"filename": saved["filename"],
"prompt": bundle.positive,
"negative_prompt": bundle.negative,
"backend": "rp_chat",
"persona_id": persona_id,
"prompt_mode": "direct",
}
result = await ComfyUIClient().generate_image(
bundle.positive,
negative_prompt=bundle.negative,
)
if result.get("ok"):
result["backend"] = "comfyui_local"
result["prompt_mode"] = "direct"
result["negative_prompt"] = bundle.negative
return result
async def generate_image(
db: Session,
*,
@@ -54,25 +94,49 @@ async def generate_image(
return {"ok": False, "error": "Нужен draw_self=true или scene_description"}
appearance = (card.get("appearance_tags") or "").strip()
if draw_self and not appearance:
return {
"ok": False,
"error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»",
}
lora_name = (card.get("lora_name") or "").strip()
lora_weight = float(card.get("lora_weight") or 0.8)
persona_id = (card.get("rp_persona_id") or "").strip() or "default"
backend = "rp_chat" if settings.rp_chat_enabled else "comfyui_local"
if draw_self:
if not appearance:
return {
"ok": False,
"error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»",
}
bundle = build_draw_self_prompt(
appearance,
lora_name=lora_name,
lora_weight=lora_weight,
)
return await _generate_from_bundle(bundle, backend=backend, persona_id=persona_id)
scene = scene_description.strip()
if looks_like_booru_tags(scene):
if not appearance:
bundle = build_scene_tags_prompt(scene, "", lora_name=lora_name, lora_weight=lora_weight)
else:
bundle = build_scene_tags_prompt(
scene,
appearance,
lora_name=lora_name,
lora_weight=lora_weight,
)
return await _generate_from_bundle(bundle, backend=backend, persona_id=persona_id)
messages = _session_messages(db, session_id)
if scene_description.strip():
messages = messages + [{"role": "user", "content": scene_description.strip()}]
elif draw_self and messages:
messages = messages + [{"role": "user", "content": "Illustrate the current scene with the character."}]
elif draw_self:
messages = [{"role": "user", "content": "Portrait of the character, looking at viewer, friendly expression."}]
messages = messages + [{"role": "user", "content": scene}]
if settings.rp_chat_enabled:
appearance_override = (card.get("appearance_tags") or "").strip() or None
return await _generate_via_rp_chat(card, messages, appearance_override)
return await _generate_via_rp_chat(
card,
messages,
appearance_override=appearance or None,
)
return await _generate_via_local_comfy(scene_description or "anime character portrait")
fallback = f"{appearance}, {scene}" if appearance else scene
return await ComfyUIClient().generate_image(fallback)
async def _generate_via_rp_chat(
@@ -119,13 +183,8 @@ async def _generate_via_rp_chat(
"url": saved["url"],
"filename": saved["filename"],
"prompt": positive,
"negative_prompt": negative,
"backend": "rp_chat",
"persona_id": persona_id,
"prompt_mode": "llm",
}
async def _generate_via_local_comfy(prompt: str) -> dict[str, Any]:
result = await ComfyUIClient().generate_image(prompt)
if result.get("ok"):
result["backend"] = "comfyui_local"
return result
+2 -2
View File
@@ -30,6 +30,6 @@
- add_shopping_items, list_shopping_lists, check_shopping_item
Картинки:
- «Нарисуй себя» → generate_image с draw_self=true
- Другая сцена → generate_image с scene_description на английском (booru-теги)
- «Нарисуй себя» → generate_image с draw_self=true (портрет по appearance_tags, LLM sd-prompt не нужен)
- Другая сцена → generate_image с scene_description на английском (booru-теги; если теги — тоже без LLM)
- Внешность персонажа задаётся в настройках карточки, не выдумывай теги
+25
View File
@@ -0,0 +1,25 @@
from app.homelab.anima_prompt import (
build_draw_self_prompt,
build_scene_tags_prompt,
looks_like_booru_tags,
)
def test_build_draw_self_prompt_includes_appearance():
bundle = build_draw_self_prompt("silver_hair, wolf_ears, blue_eyes")
assert "silver_hair" in bundle.positive
assert "wolf_ears" in bundle.positive
assert "looking_at_viewer" in bundle.positive
assert "POV:" not in bundle.positive
assert ". " not in bundle.positive
assert "worst quality" in bundle.negative
def test_build_draw_self_prompt_lora():
bundle = build_draw_self_prompt("1girl", lora_name="rin_lora", lora_weight=0.75)
assert "<lora:rin_lora:0.75>" in bundle.positive
def test_looks_like_booru_tags():
assert looks_like_booru_tags("1girl, smile, indoors")
assert not looks_like_booru_tags("draw a picture of a cat on the moon")
+20
View File
@@ -0,0 +1,20 @@
from datetime import date
from app.fitness.charts import linear_regression, week_start
def test_week_start_monday():
assert week_start(date(2026, 6, 13)) == date(2026, 6, 8)
def test_linear_regression_increasing():
points = [(0.0, 1.0), (1.0, 2.0), (2.0, 3.0)]
fit = linear_regression(points)
assert fit is not None
assert abs(fit["slope"] - 1.0) < 1e-9
assert abs(fit["intercept"] - 1.0) < 1e-9
def test_linear_regression_requires_two_points():
assert linear_regression([(0.0, 5.0)]) is None
assert linear_regression([]) is None
+48
View File
@@ -352,6 +352,45 @@ export interface FitnessHistory {
summaries: FitnessDayOverview[];
}
export interface FitnessChartPoint {
index: number;
value: number | null;
has_data: boolean;
days_with_data?: number;
week_start?: string;
week_end?: string;
date?: string;
}
export interface FitnessChartTrend {
slope_per_week?: number;
slope_per_day?: number;
intercept: number;
points_with_data: number;
line: Array<{ index: number; value: number; week_start?: string; date?: string }>;
}
export interface FitnessChartSeries {
key: string;
label: string;
unit: string;
points: FitnessChartPoint[];
trend: FitnessChartTrend | null;
data_points: number;
}
export interface FitnessChartsResponse {
end_date: string;
weeks: number;
granularity: "week" | "day";
first_week_start: string;
last_week_start: string;
days_with_data: number;
weeks_with_data: number;
series: Record<string, FitnessChartSeries>;
daily_series: Record<string, FitnessChartSeries> | null;
}
export interface FitnessSnapshot {
profile: FitnessProfile | null;
today: FitnessDailySummary;
@@ -598,6 +637,15 @@ export const api = {
return request<FitnessHistory>(`/api/v1/fitness/history?${params}`);
},
getFitnessCharts: (weeks = 52, trend = true, end?: string) => {
const params = new URLSearchParams({
weeks: String(weeks),
trend: String(trend),
});
if (end) params.set("end", end);
return request<FitnessChartsResponse>(`/api/v1/fitness/charts?${params}`);
},
updateFitnessProfile: (updates: Partial<FitnessProfile>) =>
request<{ ok: boolean; profile: FitnessProfile }>("/api/v1/fitness/profile", {
method: "PUT",
+98
View File
@@ -0,0 +1,98 @@
.fitness-charts-section {
overflow: hidden;
}
.fitness-charts-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.fitness-charts-subtitle {
margin: 0.25rem 0 0;
color: #8b95a8;
font-size: 0.85rem;
}
.fitness-chart-status {
margin: 0;
color: #8b95a8;
font-size: 0.9rem;
}
.fitness-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 0.85rem;
}
.fitness-chart-card {
background: #10141c;
border: 1px solid #242b38;
border-radius: 8px;
padding: 0.75rem;
min-width: 0;
}
.fitness-chart-card-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.35rem;
flex-wrap: wrap;
}
.fitness-chart-card-header h4 {
margin: 0;
font-size: 0.92rem;
}
.fitness-chart-unit {
color: #8b95a8;
font-weight: 400;
}
.fitness-chart-meta {
font-size: 0.75rem;
color: #7d8799;
}
.fitness-chart-svg {
width: 100%;
height: auto;
display: block;
}
.fitness-chart-grid {
stroke: #232a36;
stroke-width: 1;
}
.fitness-chart-axis {
fill: #7d8799;
font-size: 10px;
}
.fitness-chart-dot {
fill: #5b9bd5;
stroke: #151922;
stroke-width: 1.5;
}
.fitness-chart-trend {
fill: none;
stroke: #d4a15a;
stroke-width: 2;
stroke-dasharray: 5 4;
opacity: 0.9;
}
@media (max-width: 768px) {
.fitness-charts-grid {
grid-template-columns: 1fr;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { useMemo } from "react";
import { FitnessChartPoint, FitnessChartSeries, FitnessChartsResponse } from "../api/client";
import "./FitnessCharts.css";
const CHART_KEYS = ["weight_kg", "calories", "protein_g", "water_l", "steps", "body_fat_pct"] as const;
interface MetricChartProps {
series: FitnessChartSeries;
showTrend: boolean;
granularity: "week" | "day";
}
function formatTick(point: FitnessChartPoint, granularity: "week" | "day") {
if (granularity === "day") {
const d = new Date(`${point.date}T12:00:00`);
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
}
const d = new Date(`${point.week_start}T12:00:00`);
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
}
function MetricChart({ series, showTrend, granularity }: MetricChartProps) {
const layout = useMemo(() => {
const width = 640;
const height = 168;
const pad = { top: 12, right: 12, bottom: 28, left: 44 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const active = series.points.filter((p) => p.has_data && p.value != null);
if (active.length === 0) {
return null;
}
const xMax = Math.max(1, series.points.length - 1);
const values = active.map((p) => p.value as number);
let yMin = Math.min(...values);
let yMax = Math.max(...values);
if (yMin === yMax) {
yMin -= 1;
yMax += 1;
} else {
const padY = (yMax - yMin) * 0.12;
yMin -= padY;
yMax += padY;
}
const xScale = (index: number) => pad.left + (index / xMax) * plotW;
const yScale = (value: number) => pad.top + plotH - ((value - yMin) / (yMax - yMin)) * plotH;
const dots = active.map((p) => ({
x: xScale(p.index),
y: yScale(p.value as number),
label: formatTick(p, granularity),
value: p.value as number,
}));
let trendPath = "";
if (showTrend && series.trend?.line) {
const trendPoints = series.trend.line
.map((p) => `${xScale(p.index)},${yScale(p.value)}`)
.join(" ");
trendPath = trendPoints;
}
const yTicks = [yMin, (yMin + yMax) / 2, yMax];
const labelIndexes = active.length <= 4
? active.map((p) => p.index)
: [active[0].index, active[active.length - 1].index];
return {
width,
height,
pad,
plotW,
plotH,
yMin,
yMax,
yScale,
xScale,
dots,
trendPath,
yTicks,
labelIndexes,
};
}, [granularity, series, showTrend]);
if (!layout) {
return null;
}
const slopeLabel =
granularity === "day" && series.trend && "slope_per_day" in series.trend
? `${(series.trend as { slope_per_day: number }).slope_per_day > 0 ? "+" : ""}${(series.trend as { slope_per_day: number }).slope_per_day}/день`
: series.trend && "slope_per_week" in series.trend
? `${(series.trend as { slope_per_week: number }).slope_per_week > 0 ? "+" : ""}${(series.trend as { slope_per_week: number }).slope_per_week}/нед`
: null;
return (
<article className="fitness-chart-card">
<div className="fitness-chart-card-header">
<h4>
{series.label} <span className="fitness-chart-unit">({series.unit})</span>
</h4>
<span className="fitness-chart-meta">
{series.data_points} {granularity === "day" ? "дн." : "нед."}
{showTrend && slopeLabel ? ` · тренд ${slopeLabel}` : ""}
</span>
</div>
<svg
className="fitness-chart-svg"
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="img"
aria-label={`График ${series.label}`}
>
{layout.yTicks.map((tick) => (
<g key={tick}>
<line
x1={layout.pad.left}
x2={layout.pad.left + layout.plotW}
y1={layout.yScale(tick)}
y2={layout.yScale(tick)}
className="fitness-chart-grid"
/>
<text x={layout.pad.left - 6} y={layout.yScale(tick) + 4} className="fitness-chart-axis">
{tick.toFixed(tick >= 100 ? 0 : 1)}
</text>
</g>
))}
{showTrend && layout.trendPath ? (
<polyline points={layout.trendPath} className="fitness-chart-trend" />
) : null}
{layout.dots.map((dot) => (
<g key={`${dot.label}-${dot.value}`}>
<circle cx={dot.x} cy={dot.y} r={4.5} className="fitness-chart-dot" />
<title>
{dot.label}: {dot.value.toFixed(1)} {series.unit}
</title>
</g>
))}
{layout.labelIndexes.map((idx) => {
const point = series.points[idx];
if (!point?.has_data) return null;
return (
<text
key={idx}
x={layout.xScale(idx)}
y={layout.height - 8}
textAnchor="middle"
className="fitness-chart-axis"
>
{formatTick(point, granularity)}
</text>
);
})}
</svg>
</article>
);
}
interface FitnessChartsProps {
data: FitnessChartsResponse | null;
showTrend: boolean;
loading?: boolean;
}
export default function FitnessCharts({ data, showTrend, loading }: FitnessChartsProps) {
if (loading) {
return <p className="fitness-chart-status">Загрузка графиков</p>;
}
if (!data) {
return null;
}
const granularity = data.granularity;
const source =
granularity === "day" && data.daily_series ? data.daily_series : data.series;
const charts = CHART_KEYS.map((key) => source[key]).filter(
(series) => series && series.data_points > 0,
);
return (
<section className="fitness-section fitness-charts-section">
<div className="fitness-charts-head">
<div>
<h3>Динамика за год</h3>
<p className="fitness-charts-subtitle">
{granularity === "day"
? `Мало данных (${data.days_with_data} дн.) — показаны дневные точки`
: `Недельные точки · заполнено ${data.weeks_with_data} из ${data.weeks} нед.`}
</p>
</div>
</div>
{charts.length === 0 ? (
<p className="fitness-empty">
Пока нет данных для графиков. Логируй еду, воду, шаги или вес точки появятся автоматически.
</p>
) : (
<div className="fitness-charts-grid">
{charts.map((series) => (
<MetricChart
key={series.key}
series={series}
showTrend={showTrend}
granularity={granularity}
/>
))}
</div>
)}
</section>
);
}
-417
View File
@@ -1,417 +0,0 @@
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { api, ChatMessage, ChatSession } from "../api/client";
import PomodoroWidget from "../components/PomodoroWidget";
import { usePomodoro } from "../context/PomodoroContext";
import "./Chat.css";
function shouldShowMessage(msg: ChatMessage): boolean {
if (msg.role === "tool") return false;
if (msg.role === "assistant" && msg.tool_calls_json) return false;
if (msg.role === "assistant" && !msg.content.trim()) return false;
return true;
}
function noticeLabel(content: string): string {
if (content.startsWith("⏱")) return "таймер";
if (content.startsWith("📋")) return "задачи";
if (content.startsWith("🔀")) return "git";
if (content.startsWith("🧠")) return "память";
if (content.startsWith("💪")) return "фитнес";
if (content.startsWith("🌤")) return "погода";
if (content.startsWith("🎨")) return "картинка";
if (content.startsWith("⚠️")) return "сервер";
if (content.startsWith("🛒")) return "покупки";
if (content.startsWith("📅")) return "напоминание";
return "система";
}
function roleLabel(role: string, content = ""): string {
if (role === "notice") return noticeLabel(content);
if (role === "character") return "assistant";
if (role === "user") return "вы";
return role;
}
function messageClassName(role: string): string {
if (role === "character") return "assistant";
return role;
}
export default function Chat() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeId, setActiveId] = useState<number | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState("");
const [pendingPhase, setPendingPhase] = useState<
"thinking" | "preparing" | "generating" | "tools"
>("thinking");
const [chatError, setChatError] = useState<string | null>(null);
const tempMessageId = useRef(0);
const messagesRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollRafRef = useRef<number | null>(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 () => {
const data = await api.listSessions();
setSessions(data);
if (!activeId && data.length > 0) {
setActiveId(data[0].id);
}
};
const loadMessages = async (sessionId: number) => {
const data = await api.getSession(sessionId);
setMessages(data.messages);
};
useEffect(() => {
loadSessions().catch(console.error);
}, []);
useEffect(() => {
if (activeId) {
loadMessages(activeId).catch(console.error);
}
}, [activeId]);
const scrollToBottom = useCallback((smooth = false) => {
const container = messagesRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
}, []);
useEffect(() => {
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
}
scrollRafRef.current = requestAnimationFrame(() => {
scrollToBottom(!streaming);
scrollRafRef.current = null;
});
return () => {
if (scrollRafRef.current !== null) {
cancelAnimationFrame(scrollRafRef.current);
}
};
}, [messages, streaming, loading, chatError, scrollToBottom]);
const dismissKeyboard = useCallback(() => {
inputRef.current?.blur();
}, []);
const waitingForStream = loading && !streaming;
const nextTempId = () => {
tempMessageId.current -= 1;
return tempMessageId.current;
};
const appendNotice = useCallback((content: string) => {
setMessages((prev) => [
...prev,
{
id: nextTempId(),
role: "notice",
content,
created_at: new Date().toISOString(),
},
]);
}, []);
const pendingLabel =
pendingPhase === "tools"
? "Выполняю команды…"
: pendingPhase === "preparing"
? "Собираю контекст…"
: pendingPhase === "generating"
? "Генерирую ответ…"
: "Думаю…";
useEffect(() => {
const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0;
if (seq > lastNotifySeq) {
setLastNotifySeq(seq);
refreshPomodoro().catch(console.error);
if (activeId) {
if (loading) {
pendingHistoryReload.current = true;
} else {
loadMessages(activeId).catch(console.error);
}
}
}
}, [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();
setActiveId(session.id);
setMessages([]);
};
const handleDelete = async (id: number) => {
await api.deleteSession(id);
const data = await api.listSessions();
setSessions(data);
if (activeId === id) {
setActiveId(data[0]?.id ?? null);
setMessages([]);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!input.trim() || !activeId || loading) return;
const text = input.trim();
setInput("");
dismissKeyboard();
setLoading(true);
setStreaming("");
setPendingPhase("thinking");
setChatError(null);
const tempUser: ChatMessage = {
id: nextTempId(),
role: "user",
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, tempUser]);
try {
let assistantText = "";
for await (const chunk of api.sendMessage(activeId, text)) {
if (chunk.event === "status") {
if (chunk.data.phase === "preparing") {
setPendingPhase("preparing");
}
if (chunk.data.phase === "generating") {
setPendingPhase("generating");
}
if (chunk.data.phase === "tools") {
setPendingPhase("tools");
assistantText = "";
setStreaming("");
}
}
if (chunk.event === "token") {
assistantText += chunk.data.content;
setPendingPhase("generating");
setStreaming(assistantText);
}
if (chunk.event === "notice") {
appendNotice(chunk.data.content);
if (String(chunk.data.content).startsWith("⏱")) {
refreshPomodoro();
}
}
if (chunk.event === "pomodoro") {
refreshPomodoro();
}
if (chunk.event === "done") {
const tail = assistantText.trim();
if (tail) {
setMessages((prev) => [
...prev,
{
id: nextTempId(),
role: "assistant",
content: tail,
created_at: new Date().toISOString(),
},
]);
}
setStreaming("");
setChatError(null);
await loadMessages(activeId);
await loadSessions();
}
if (chunk.event === "error") {
throw new Error(chunk.data.message);
}
}
} catch (err) {
console.error(err);
const message = err instanceof Error ? err.message : "Ошибка чата";
setChatError(message);
setStreaming("");
if (activeId) {
await loadMessages(activeId);
}
} finally {
setLoading(false);
if (pendingHistoryReload.current && activeId) {
pendingHistoryReload.current = false;
loadMessages(activeId).catch(console.error);
}
}
};
const visibleMessages = messages.filter(shouldShowMessage);
return (
<div className="chat-layout">
<aside className="chat-sidebar">
<button className="primary-btn" onClick={handleNewChat}>
+ Новый чат
</button>
<PomodoroWidget />
<ul className="session-list">
{sessions.map((session) => (
<li key={session.id} className={activeId === session.id ? "active" : ""}>
<button onClick={() => setActiveId(session.id)}>{session.title}</button>
<button className="delete-btn" onClick={() => handleDelete(session.id)}>
×
</button>
</li>
))}
</ul>
</aside>
<section className="chat-main">
{!activeId ? (
<div className="chat-empty">Создайте новый чат, чтобы начать</div>
) : (
<>
<div className="chat-mobile-bar">
<select
className="chat-session-select"
value={activeId}
onChange={(e) => setActiveId(Number(e.target.value))}
aria-label="Выбор чата"
>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.title}
</option>
))}
</select>
<button type="button" className="chat-mobile-new" onClick={handleNewChat}>
+ Новый
</button>
</div>
<div
className="messages"
ref={messagesRef}
onClick={dismissKeyboard}
>
{visibleMessages.map((msg) => (
<div key={msg.id} className={`message message-${messageClassName(msg.role)}`}>
<div className="message-role">{roleLabel(msg.role, msg.content)}</div>
<div className="message-content">
{msg.role === "assistant" || msg.role === "notice" || msg.role === "character" ? (
<ReactMarkdown>{msg.content}</ReactMarkdown>
) : (
msg.content
)}
</div>
</div>
))}
{waitingForStream && (
<div className="message message-assistant message-pending" aria-live="polite">
<div className="message-role">assistant</div>
<div className="message-content message-pending-content">
<span className="typing-indicator" aria-hidden="true">
<span />
<span />
<span />
</span>
<span className="typing-label">{pendingLabel}</span>
</div>
</div>
)}
{streaming && (
<div className="message message-assistant">
<div className="message-role">assistant</div>
<div className="message-content">
<ReactMarkdown>{streaming}</ReactMarkdown>
</div>
</div>
)}
{chatError && (
<div className="message message-error" role="alert">
<div className="message-role">ошибка</div>
<div className="message-content">{chatError}</div>
</div>
)}
<div className="messages-bottom-anchor" aria-hidden="true" />
</div>
<form className="chat-input" onSubmit={handleSubmit}>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Напишите сообщение..."
rows={2}
enterKeyHint="send"
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
/>
<button type="submit" disabled={loading || !input.trim()}>
{loading ? "..." : "Отправить"}
</button>
</form>
</>
)}
</section>
</div>
);
}
+97 -2
View File
@@ -1,10 +1,14 @@
.fitness-page {
width: 100%;
max-width: 900px;
min-width: 0;
margin: 0 auto;
padding: 1.5rem;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-x: clip;
}
.fitness-header {
@@ -62,11 +66,16 @@
.fitness-day-title {
text-align: center;
flex: 1;
flex: 1 1 auto;
min-width: 0;
}
.fitness-day-title h3 {
margin: 0 0 0.25rem;
min-height: 1.35em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fitness-day-title input[type="date"] {
@@ -84,6 +93,8 @@
overflow-x: auto;
margin-bottom: 1rem;
padding-bottom: 0.25rem;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
.fitness-week-day {
@@ -125,6 +136,17 @@
border: 1px solid #2a2f3a;
border-radius: 10px;
padding: 1rem 1.25rem;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.fitness-day-panel {
min-height: 11rem;
}
.fitness-progress {
min-width: 0;
}
.fitness-section h3 {
@@ -146,10 +168,20 @@
.fitness-progress-header {
display: flex;
justify-content: space-between;
align-items: baseline;
flex-wrap: wrap;
gap: 0.15rem 0.5rem;
font-size: 0.85rem;
margin-bottom: 0.2rem;
}
.fitness-progress-header span:last-child {
text-align: right;
min-width: 0;
flex: 1 1 auto;
overflow-wrap: anywhere;
}
.fitness-progress-track {
height: 8px;
background: #0f1218;
@@ -195,6 +227,7 @@
margin: 0.75rem 0 0;
font-size: 0.9rem;
color: #8b95a8;
overflow-wrap: anywhere;
}
.fitness-body-calc {
@@ -237,6 +270,13 @@
font-size: 0.85rem;
}
.fitness-table-wrap {
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.fitness-table-wide th,
.fitness-table-wide td {
white-space: nowrap;
@@ -251,10 +291,13 @@
.fitness-log-list li {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid #1e2430;
font-size: 0.9rem;
min-width: 0;
overflow-wrap: anywhere;
}
.fitness-log-list button {
@@ -349,6 +392,7 @@
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow-wrap: anywhere;
}
.fitness-activity-block p {
@@ -359,6 +403,7 @@
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: #c5d0e0;
overflow-wrap: anywhere;
}
.fitness-workout-stats h4 {
@@ -371,3 +416,53 @@
margin: 0;
}
.fitness-charts-controls {
display: flex;
justify-content: flex-end;
}
.fitness-charts-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
color: #a8b0bd;
cursor: pointer;
user-select: none;
}
.fitness-charts-toggle input {
accent-color: #4a7cff;
}
@media (max-width: 768px) {
.fitness-page {
padding: 1rem 0.75rem;
}
.fitness-section {
padding: 0.85rem 1rem;
}
.fitness-header {
align-items: stretch;
}
.fitness-header-actions {
width: 100%;
justify-content: flex-end;
}
.fitness-progress-header {
font-size: 0.8rem;
}
.fitness-day-panel {
min-height: 10rem;
}
.fitness-profile-form {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
}
+28 -5
View File
@@ -3,12 +3,14 @@ import {
api,
BodyCompositionComputed,
BodyMetric,
FitnessChartsResponse,
FitnessDailySummary,
FitnessHistory,
FitnessProfile,
FitnessReminder,
FitnessSnapshot,
} from "../api/client";
import FitnessCharts from "../components/FitnessCharts";
import "./Fitness.css";
function todayIso() {
@@ -89,26 +91,33 @@ export default function Fitness() {
const [calcResult, setCalcResult] = useState<BodyCompositionComputed | null>(null);
const [showRaw, setShowRaw] = useState(false);
const [loading, setLoading] = useState(false);
const [charts, setCharts] = useState<FitnessChartsResponse | null>(null);
const [chartsLoading, setChartsLoading] = useState(false);
const [showTrend, setShowTrend] = useState(true);
const load = useCallback(async (day: string = selectedDate) => {
setLoading(true);
setChartsLoading(true);
try {
const [data, summary, hist] = await Promise.all([
const [data, summary, hist, chartData] = await Promise.all([
api.getFitnessSnapshot(),
api.getFitnessSummary(day),
api.getFitnessHistory(7, day),
api.getFitnessCharts(52, showTrend, day),
]);
setSnapshot(data);
setDaySummary(summary);
setHistory(hist);
setCharts(chartData);
if (data.profile) setProfile(data.profile);
setMessage("");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setLoading(false);
setChartsLoading(false);
}
}, [selectedDate]);
}, [selectedDate, showTrend]);
useEffect(() => {
load(selectedDate).catch(console.error);
@@ -245,7 +254,7 @@ export default function Fitness() {
)}
{totals && targets ? (
<>
<div className="fitness-day-panel">
{activity ? (
<div className="fitness-activity-block">
<p>
@@ -304,12 +313,24 @@ export default function Fitness() {
unit="л"
/>
</div>
</>
</div>
) : (
<p className="fitness-empty">Нет записей за этот день</p>
<p className="fitness-empty fitness-day-panel">Нет записей за этот день</p>
)}
</section>
<div className="fitness-charts-controls">
<label className="fitness-charts-toggle">
<input
type="checkbox"
checked={showTrend}
onChange={(e) => setShowTrend(e.target.checked)}
/>
Линия тренда (МНК)
</label>
</div>
<FitnessCharts data={charts} showTrend={showTrend} loading={chartsLoading} />
<section className="fitness-section">
<h3>Профиль и цели</h3>
<form className="fitness-profile-form" onSubmit={handleProfileSave}>
@@ -543,6 +564,7 @@ export default function Fitness() {
<section className="fitness-section">
<h3>Антропометрия</h3>
<div className="fitness-table-wrap">
<table className="fitness-table fitness-table-wide">
<thead>
<tr>
@@ -571,6 +593,7 @@ export default function Fitness() {
))}
</tbody>
</table>
</div>
</section>
<section className="fitness-section">
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/context/pomodorocontext.tsx","./src/hooks/usevisualviewport.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/fitness.tsx","./src/pages/memory.tsx","./src/pages/pomodoro.tsx","./src/pages/reminders.tsx","./src/pages/shopping.tsx","./src/utils/charactercard.ts","./src/utils/pomodoro.ts","./src/utils/pomodorocountdown.ts","./src/utils/time.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/fitnesscharts.tsx","./src/components/messagebubble.tsx","./src/components/messagelist.tsx","./src/components/pomodorowidget.tsx","./src/components/requireauth.tsx","./src/context/authcontext.tsx","./src/context/pomodorocontext.tsx","./src/hooks/usepomodoronotify.ts","./src/hooks/usethrottledstreaming.ts","./src/hooks/usevisualviewport.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/fitness.tsx","./src/pages/login.tsx","./src/pages/memory.tsx","./src/pages/pomodoro.tsx","./src/pages/reminders.tsx","./src/pages/settings.tsx","./src/pages/shopping.tsx","./src/utils/charactercard.ts","./src/utils/mergemessages.ts","./src/utils/pomodoro.ts","./src/utils/pomodorocountdown.ts","./src/utils/time.ts"],"version":"5.9.3"}
+21
View File
@@ -141,3 +141,24 @@ class HaClient:
return int(session["id"])
created = await self.create_session("Telegram")
return int(created["id"])
async def download_media(self, path_or_url: str, *, ha_api_base: str | None = None) -> bytes:
base = ha_api_base or self.base_url
url = resolve_media_url(base, path_or_url)
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.get(url, headers=self._headers())
if response.status_code >= 400:
raise HaApiError(response.text.strip() or f"HTTP {response.status_code}", response.status_code)
return response.content
def resolve_media_url(ha_api_base: str, path_or_url: str) -> str:
raw = (path_or_url or "").strip()
if raw.startswith("http://") or raw.startswith("https://"):
return raw
origin = ha_api_base.rstrip("/")
if origin.endswith("/api/v1"):
origin = origin[: -len("/api/v1")]
if not raw.startswith("/"):
raw = f"/{raw}"
return f"{origin}{raw}"
+9 -2
View File
@@ -13,7 +13,8 @@ from bot.config import Settings
from bot.filters import IsLinked
from bot.ha_client import HaClient
from bot.sse import SseChunk
from bot.notify_worker import advance_cursors, send_text
from bot.notify_worker import advance_cursors
from bot.notice_delivery import send_notice_content
from bot.storage import LinkedUser, Storage
router = Router()
@@ -52,7 +53,13 @@ async def _run_chat_stream(
elif chunk.event == "notice":
content = str(chunk.data.get("content") or "").strip()
if content:
await send_text(message.bot, message.chat.id, content)
await send_notice_content(
message.bot,
message.chat.id,
content,
HaClient(settings.ha_api_base_url, linked.api_token),
ha_api_base=settings.ha_api_base_url,
)
elif chunk.event == "error":
err = str(chunk.data.get("message") or "Ошибка генерации")
await message.answer(err)
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import logging
import re
from aiogram import Bot
from aiogram.types import BufferedInputFile
from bot.ha_client import HaClient
from bot.tg_util import send_text, split_telegram_message
logger = logging.getLogger(__name__)
IMAGE_MD_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
TG_CAPTION_MAX = 1024
def parse_notice_content(content: str) -> tuple[str, list[str]]:
image_paths: list[str] = []
def _replace(match: re.Match[str]) -> str:
image_paths.append(match.group(2).strip())
return ""
text = IMAGE_MD_RE.sub(_replace, content)
text = _plain_markdown(text)
return text, image_paths
def _plain_markdown(text: str) -> str:
text = re.sub(r"```[^\n]*\n(.*?)```", r"\1", text, flags=re.DOTALL)
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"\*([^*]+)\*", r"\1", text)
text = re.sub(r"__([^_]+)__", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
async def send_notice_content(
bot: Bot,
chat_id: int,
content: str,
client: HaClient,
*,
ha_api_base: str,
) -> None:
caption, image_paths = parse_notice_content(content)
if not image_paths:
await send_text(bot, chat_id, caption or content)
return
caption_chunks = split_telegram_message(caption, TG_CAPTION_MAX) if caption else []
first_caption = caption_chunks[0] if caption_chunks else None
for index, image_path in enumerate(image_paths):
try:
image_bytes = await client.download_media(image_path, ha_api_base=ha_api_base)
except Exception:
logger.exception("Failed to download image %s for chat_id=%s", image_path, chat_id)
fallback = f"{caption}\n\n(не удалось загрузить: {image_path})".strip()
await send_text(bot, chat_id, fallback or image_path)
return
cap = first_caption if index == 0 else None
await bot.send_photo(
chat_id,
BufferedInputFile(image_bytes, filename="image.png"),
caption=cap or None,
)
if len(caption_chunks) > 1:
for extra in caption_chunks[1:]:
await send_text(bot, chat_id, extra)
elif caption and not first_caption and len(caption) > TG_CAPTION_MAX:
await send_text(bot, chat_id, caption)
+8 -24
View File
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
from aiogram import Bot
from bot.ha_client import HaClient
from bot.notice_delivery import send_notice_content
from bot.storage import LinkedUser, Storage
if TYPE_CHECKING:
@@ -14,29 +15,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
NOTICE_ROLES = frozenset({"notice", "character"})
TG_MAX_LEN = 4096
def split_telegram_message(text: str, limit: int = TG_MAX_LEN) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
split_at = remaining.rfind("\n", 0, limit)
if split_at <= 0:
split_at = limit
chunks.append(remaining[:split_at])
remaining = remaining[split_at:].lstrip("\n")
return chunks
async def send_text(bot: Bot, chat_id: int, text: str) -> None:
for chunk in split_telegram_message(text):
await bot.send_message(chat_id, chunk)
async def advance_cursors(
@@ -113,7 +91,13 @@ async def sync_notices_for_user(
pending.sort(key=lambda item: item[0])
for _, content in pending:
try:
await send_text(bot, user.telegram_id, content)
await send_notice_content(
bot,
user.telegram_id,
content,
client,
ha_api_base=ha_base_url,
)
except Exception:
logger.exception("Failed to send notice to telegram_id=%s", user.telegram_id)
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from aiogram import Bot
TG_MAX_LEN = 4096
def split_telegram_message(text: str, limit: int = TG_MAX_LEN) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
split_at = remaining.rfind("\n", 0, limit)
if split_at <= 0:
split_at = limit
chunks.append(remaining[:split_at])
remaining = remaining[split_at:].lstrip("\n")
return chunks
async def send_text(bot: Bot, chat_id: int, text: str) -> None:
for chunk in split_telegram_message(text):
await bot.send_message(chat_id, chunk)
@@ -0,0 +1,23 @@
from bot.ha_client import resolve_media_url
from bot.notice_delivery import parse_notice_content
def test_resolve_media_url_relative():
url = resolve_media_url(
"https://home.example.com/api/v1",
"/api/v1/media/generated/abc.png",
)
assert url == "https://home.example.com/api/v1/media/generated/abc.png"
def test_parse_notice_content_extracts_image():
content = (
"🎨 **Картинка готова**\n\n"
"![image](/api/v1/media/generated/abc.png)\n\n"
"**Comfy (+):**\n```\n1girl, smile\n```"
)
text, paths = parse_notice_content(content)
assert paths == ["/api/v1/media/generated/abc.png"]
assert "![image]" not in text
assert "Картинка готова" in text
assert "1girl, smile" in text