added RAG, Multiuser, TG bot
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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""]
|
||||
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"
|
||||
return _format_image_generation_notice(data)
|
||||
|
||||
if tool_name == "create_shopping_list" and data.get("ok"):
|
||||
lst = data.get("list") or {}
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
- Внешность персонажа задаётся в настройках карточки, не выдумывай теги
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
"\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
|
||||
Reference in New Issue
Block a user