+ {series.label} ({series.unit}) +
+ + {series.data_points} {granularity === "day" ? "дн." : "нед."} + ++ Нет точек в этом диапазоне.{" "} + +
+diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py
index 86f8b56..0119f4b 100644
--- a/backend/app/chat/notices.py
+++ b/backend/app/chat/notices.py
@@ -66,6 +66,7 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
"lookup_exercise",
"calc_fitness_targets",
"calc_body_composition",
+ "get_body_metrics",
"get_weather",
"get_morning_briefing",
"list_shopping_lists",
diff --git a/backend/app/chat/service.py b/backend/app/chat/service.py
index f330dde..5c9abe3 100644
--- a/backend/app/chat/service.py
+++ b/backend/app/chat/service.py
@@ -42,7 +42,10 @@ _DOMAIN_CACHE: dict[str, tuple[float, str]] = {}
_DOMAIN_TTL_SEC = 60.0
_DOMAIN_KEYWORDS: dict[str, tuple[str, ...]] = {
- "fitness": ("фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир"),
+ "fitness": (
+ "фитнес", "тренир", "калори", "еда", "вода", "вес", "workout", "meal", "белок", "жир",
+ "талия", "обхват", "антроп", "ffmi", "whr", "navy", "состав тела", "замер",
+ ),
"shopping": ("покуп", "магазин", "список", "shopping", "корзин"),
"reminders": ("напомин", "календар", "событи", "дедлайн", "встреч", "план"),
"projects": ("taiga", "gitea", "задач", "проект", "git", "issue", "коммит", "ветк"),
diff --git a/backend/app/fitness/charts.py b/backend/app/fitness/charts.py
index 9bde0c6..eed53b6 100644
--- a/backend/app/fitness/charts.py
+++ b/backend/app/fitness/charts.py
@@ -64,6 +64,21 @@ def _last(values: list[tuple[date, float]]) -> float | None:
return values[-1][1]
+def _day_has_activity(
+ day: date,
+ daily_flags: dict[date, set[str]],
+ workout_kcal_by_day: dict[date, float],
+) -> bool:
+ flags = daily_flags.get(day, set())
+ if "nutrition" in flags or "steps" in flags:
+ return True
+ return workout_kcal_by_day.get(day, 0) > 0
+
+
+def _day_has_nutrition(day: date, daily_flags: dict[date, set[str]]) -> bool:
+ return "nutrition" in daily_flags.get(day, set())
+
+
def _profile_for_charts(row: FitnessProfile | None) -> dict[str, float | int | str | None] | None:
if row is None:
return None
@@ -312,11 +327,15 @@ def build_fitness_charts(
week_daily_values.append(totals["water_ml"] / 1000.0)
elif metric == "steps" and totals and "steps" in flags:
week_daily_values.append(totals["steps"])
- elif metric == "tdee" and profile is not None and day_cursor <= end:
+ elif metric == "tdee" and profile is not None and _day_has_activity(
+ day_cursor, daily_flags, workout_kcal_by_day
+ ):
week_daily_values.append(
_tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor)
)
- elif metric == "tdee_expected" and profile is not None and day_cursor <= end:
+ elif metric == "tdee_expected" and profile is not None and _day_has_nutrition(
+ day_cursor, daily_flags
+ ):
week_daily_values.append(
_tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor)
)
@@ -463,7 +482,13 @@ def _build_daily_series(
elif key == "steps" and totals and "steps" in flags:
value = totals["steps"]
has_data = True
- elif key == "tdee" and profile is not None and steps_by_day is not None and workout_kcal_by_day is not None:
+ elif (
+ key == "tdee"
+ and profile is not None
+ and steps_by_day is not None
+ and workout_kcal_by_day is not None
+ and _day_has_activity(d, daily_flags, workout_kcal_by_day)
+ ):
value = _tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, d)
has_data = True
elif (
@@ -471,6 +496,7 @@ def _build_daily_series(
and profile is not None
and steps_by_day is not None
and workout_kcal_by_day is not None
+ and _day_has_nutrition(d, daily_flags)
):
value = _tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, d)
has_data = True
diff --git a/backend/app/fitness/context.py b/backend/app/fitness/context.py
index 24396bd..a47ba4b 100644
--- a/backend/app/fitness/context.py
+++ b/backend/app/fitness/context.py
@@ -105,31 +105,33 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
f"(серия {stats.get('streak')} дн., {stats.get('active_kcal')} ккал активных)"
)
- latest = (snapshot.get("body_metrics") or [None])[0]
- if latest:
+ metrics = snapshot.get("body_metrics") or []
+ if metrics:
lines.append("")
- lines.append("Антропометрия (последняя):")
- parts = [f"{latest.get('weight_kg')} кг"]
- if latest.get("body_fat_pct") is not None:
- method = latest.get("body_fat_method") or "?"
- parts.append(f"жир {latest.get('body_fat_pct')}% ({method})")
- if latest.get("neck_cm"):
- parts.append(f"шея {latest.get('neck_cm')}")
- if latest.get("waist_cm"):
- parts.append(f"талия {latest.get('waist_cm')}")
- if latest.get("hip_cm"):
- parts.append(f"бёдра {latest.get('hip_cm')}")
- if latest.get("whr"):
- parts.append(f"WHR {latest.get('whr')}")
- if latest.get("ffmi"):
- parts.append(f"FFMI {latest.get('ffmi')}")
- lines.append(" · ".join(parts))
+ lines.append(f"Антропометрия — последние {len(metrics)} замеров (новые → старые):")
+ for m in metrics:
+ recorded = (m.get("recorded_at") or "")[:10]
+ parts = [f"{recorded}: {m.get('weight_kg')} кг"]
+ if m.get("body_fat_pct") is not None:
+ method = m.get("body_fat_method") or "?"
+ parts.append(f"жир {m.get('body_fat_pct')}% ({method})")
+ if m.get("waist_cm"):
+ parts.append(f"талия {m.get('waist_cm')}")
+ if m.get("neck_cm"):
+ parts.append(f"шея {m.get('neck_cm')}")
+ if m.get("hip_cm"):
+ parts.append(f"бёдра {m.get('hip_cm')}")
+ if m.get("whr"):
+ parts.append(f"WHR {m.get('whr')}")
+ if m.get("ffmi"):
+ parts.append(f"FFMI {m.get('ffmi')}")
+ lines.append(" · ".join(parts))
lines.append("")
lines.append(
"Правила: log_meal, log_water, log_weight (обхваты → Navy), log_steps (replace=true — итог за день), log_workout (date/days_ago), "
"update_fitness_log, delete_fitness_log (last=true — удалить последнюю), merge_fitness_logs, compare_fitness_days, "
- "calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
+ "calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, get_body_metrics, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. "
"TDEE факт — по залогированной активности; TDEE план — среднее за неделю (или baseline) для утреннего бюджета углеводов. "
diff --git a/backend/app/fitness/service.py b/backend/app/fitness/service.py
index 918478c..40cf3a6 100644
--- a/backend/app/fitness/service.py
+++ b/backend/app/fitness/service.py
@@ -979,7 +979,7 @@ class FitnessService:
"today": self.get_daily_summary(today),
"history": self.get_history(days=7, end_day=today),
"workout_stats": self.get_workout_stats(days=7, end_day=today),
- "body_metrics": self.list_body_metrics(limit=10),
+ "body_metrics": self.list_body_metrics(limit=15),
"reminders": self.list_reminders(),
}
diff --git a/backend/app/homelab/comfyui.py b/backend/app/homelab/comfyui.py
index 6247eb0..611886f 100644
--- a/backend/app/homelab/comfyui.py
+++ b/backend/app/homelab/comfyui.py
@@ -13,15 +13,22 @@ ANIMA_DEFAULT_NEGATIVE = (
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
)
-ROFL_PROMPTS = [
- f"{ANIMA_QUALITY_PREFIX}, confused cat in tiny business suit, server room, meme, chibi",
- f"{ANIMA_QUALITY_PREFIX}, potato with sunglasses on skateboard, absurd cartoon, silly",
- f"{ANIMA_QUALITY_PREFIX}, astronaut watering houseplant on the moon, wholesome, cute",
- f"{ANIMA_QUALITY_PREFIX}, rubber duck as judge at programming contest, comic style",
- f"{ANIMA_QUALITY_PREFIX}, llama DJ at house party, neon lights, party, silly",
+ROFL_POSES = [
+ "full_body, standing, peace_sign, looking_at_viewer, smile",
+ "sitting, on_chair, legs_up, playful, blush",
+ "lying, on_bed, stretching, sleepy, relaxed",
+ "standing, arms_up, victory_pose, happy, open_mouth",
+ "sitting, hugging_knees, shy, looking_away",
+ "full_body, walking, wind, hair_blowing, outdoors",
+ "portrait, close-up, wink, finger_gun",
+ "sitting, holding_cup, cozy, sweater, indoors",
]
+def random_rofl_pose() -> str:
+ return random.choice(ROFL_POSES)
+
+
def _use_anima(settings) -> bool:
return bool(settings.comfyui_unet.strip()) and not settings.comfyui_checkpoint.strip()
@@ -272,6 +279,3 @@ class ComfyUIClient:
}
return {"ok": False, "error": f"Таймаут генерации ({self.timeout}s)"}
-
- def random_rofl_prompt(self) -> str:
- return random.choice(ROFL_PROMPTS)
diff --git a/backend/app/homelab/watcher.py b/backend/app/homelab/watcher.py
index 78b66a7..873b887 100644
--- a/backend/app/homelab/watcher.py
+++ b/backend/app/homelab/watcher.py
@@ -8,7 +8,9 @@ import httpx
from app.config import get_settings
from app.db.base import SessionLocal
-from app.homelab.comfyui import ComfyUIClient
+from app.character.service import CharacterService
+from app.homelab.comfyui import ComfyUIClient, random_rofl_pose
+from app.homelab.image_gen import generate_image
from app.homelab.context import resolve_timezone
from app.homelab.digest import build_morning_digest
from app.homelab.monitoring import check_netdata_alerts
@@ -128,10 +130,15 @@ async def _tick_rofl() -> None:
if not await _comfyui_reachable(client.base_url):
return
- prompt = client.random_rofl_prompt()
+ card = CharacterService(db, 1).get_card().get("data", {})
+ appearance = (card.get("appearance_tags") or "").strip()
+ if not appearance:
+ return
+
+ pose = random_rofl_pose()
try:
result = await asyncio.wait_for(
- client.generate_image(prompt),
+ generate_image(db, user_id=1, draw_self=True, scene_description=pose),
timeout=settings.comfyui_timeout_sec + 15,
)
except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc:
@@ -143,7 +150,7 @@ async def _tick_rofl() -> None:
url = result.get("url", "")
post_chat_notice(
- f"🎨 **Рофл дня**\n\n\n\n_{prompt}_"
+ f"🎨 **Рофл дня**\n\n\n\n_{pose}_"
)
set_state(db, f"comfy_rofl_count_{today}", str(count + 1))
set_state(db, "last_comfy_rofl_at", now.isoformat())
diff --git a/backend/app/homelab_scoped/watcher.py b/backend/app/homelab_scoped/watcher.py
index 4d7fbf9..503453d 100644
--- a/backend/app/homelab_scoped/watcher.py
+++ b/backend/app/homelab_scoped/watcher.py
@@ -10,9 +10,11 @@ from sqlalchemy import select
from app.config import get_settings
from app.db.base import SessionLocal
from app.db.models import User
-from app.homelab.comfyui import ComfyUIClient
+from app.character.service import CharacterService
+from app.homelab.comfyui import ComfyUIClient, random_rofl_pose
from app.homelab.context import resolve_timezone
from app.homelab.digest import build_morning_digest
+from app.homelab.image_gen import generate_image
from app.homelab.monitoring import check_netdata_alerts
from app.homelab_scoped.notices import post_chat_notice
from app.homelab.state import get_state, set_state
@@ -140,10 +142,15 @@ async def _tick_rofl() -> None:
if not await _comfyui_reachable(client.base_url):
continue
- prompt = client.random_rofl_prompt()
+ card = CharacterService(db, user.id).get_card().get("data", {})
+ appearance = (card.get("appearance_tags") or "").strip()
+ if not appearance:
+ continue
+
+ pose = random_rofl_pose()
try:
result = await asyncio.wait_for(
- client.generate_image(prompt),
+ generate_image(db, user_id=user.id, draw_self=True, scene_description=pose),
timeout=settings.comfyui_timeout_sec + 15,
)
except (asyncio.TimeoutError, httpx.TimeoutException, httpx.ConnectError) as exc:
@@ -155,7 +162,7 @@ async def _tick_rofl() -> None:
url = result.get("url", "")
post_chat_notice(
- f"🎨 **Рофл дня**\n\n\n\n_{prompt}_",
+ f"🎨 **Рофл дня**\n\n\n\n_{pose}_",
user.id,
)
set_state(db, count_key, str(count + 1))
diff --git a/backend/app/tools/fitness.py b/backend/app/tools/fitness.py
index dcfec68..ee7dd01 100644
--- a/backend/app/tools/fitness.py
+++ b/backend/app/tools/fitness.py
@@ -10,6 +10,7 @@ from app.tools._dispatch import NOT_HANDLED, ToolContext
TOOL_NAMES = frozenset({
"get_fitness_summary",
"get_fitness_history",
+ "get_body_metrics",
"compare_fitness_days",
"set_fitness_profile",
"calc_fitness_targets",
@@ -25,6 +26,7 @@ TOOL_NAMES = frozenset({
"lookup_food",
"lookup_exercise",
"set_fitness_reminder",
+ "get_body_metrics",
})
TOOL_DEFINITIONS: list[dict[str, Any]] = [
@@ -49,6 +51,26 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
},
},
},
+ {
+ "type": "function",
+ "function": {
+ "name": "get_body_metrics",
+ "description": (
+ "История антропометрии: вес, % жира, обхваты, WHR, FFMI по датам. "
+ "«Как менялся вес», «динамика жира», «прошлые замеры», «что было месяц назад»."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "limit": {
+ "type": "integer",
+ "description": "Сколько последних записей, по умолчанию 20, макс. 50",
+ },
+ },
+ "required": [],
+ },
+ },
+ },
{
"type": "function",
"function": {
@@ -407,6 +429,10 @@ async def execute(name: str, arguments: dict[str, Any], ctx: ToolContext) -> Any
elif arguments.get("days_ago") is not None:
day = datetime.now(timezone.utc).date() - timedelta(days=int(arguments["days_ago"]))
return fitness.get_daily_summary(day)
+ if name == "get_body_metrics":
+ limit = max(1, min(int(arguments.get("limit") or 20), 50))
+ metrics = fitness.list_body_metrics(limit=limit)
+ return {"metrics": metrics, "count": len(metrics)}
if name == "get_fitness_history":
end_day = None
if arguments.get("end_date"):
diff --git a/frontend/src/components/FitnessCharts.css b/frontend/src/components/FitnessCharts.css
index 3405376..cebf3f4 100644
--- a/frontend/src/components/FitnessCharts.css
+++ b/frontend/src/components/FitnessCharts.css
@@ -135,6 +135,29 @@
opacity: 0.9;
}
+.fitness-chart-line {
+ fill: none;
+ stroke: #5b9bd5;
+ stroke-width: 1.5;
+ opacity: 0.55;
+}
+
+.fitness-chart-empty {
+ margin: 0.5rem 0 0;
+ color: #8b95a8;
+ font-size: 0.85rem;
+}
+
+.fitness-chart-empty-reset {
+ background: none;
+ border: none;
+ color: #5b9bd5;
+ cursor: pointer;
+ padding: 0;
+ font: inherit;
+ text-decoration: underline;
+}
+
@media (max-width: 768px) {
.fitness-charts-grid {
grid-template-columns: 1fr;
diff --git a/frontend/src/components/FitnessCharts.tsx b/frontend/src/components/FitnessCharts.tsx
index 1aff4e5..be026a0 100644
--- a/frontend/src/components/FitnessCharts.tsx
+++ b/frontend/src/components/FitnessCharts.tsx
@@ -14,6 +14,7 @@ const CHART_KEYS = [
"body_fat_pct",
] as const;
+const CONNECTED_KEYS = new Set(["weight_kg", "body_fat_pct"]);
const MIN_VIEW_SPAN = 2;
const ZOOM_FACTOR = 0.85;
@@ -28,7 +29,9 @@ interface MetricChartProps {
granularity: "week" | "day";
view: ChartView;
pointCount: number;
+ connectPoints: boolean;
onViewChange: (view: ChartView) => void;
+ onResetView: () => void;
}
function formatTick(point: FitnessChartPoint, granularity: "week" | "day") {
@@ -69,12 +72,22 @@ function MetricChart({
granularity,
view,
pointCount,
+ connectPoints,
onViewChange,
+ onResetView,
}: MetricChartProps) {
const svgRef = useRef
+ Нет точек в этом диапазоне.{" "}
+
+ Загрузка графиков…
+ {series.label} ({series.unit})
+
+
+ {series.data_points} {granularity === "day" ? "дн." : "нед."}
+
+
Колесо мыши — масштаб · перетаскивание — прокрутка
) : null}