diff --git a/.env.example b/.env.example index 603902c..f8e509e 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,9 @@ WEATHER_LAT=59.9343 WEATHER_LON=30.3351 WEATHER_LOCATION_NAME=Санкт-Петербург WEATHER_CACHE_SEC=300 +# Если локальный OpenMeteo отдаёт только temperature_2m — подставить публичный API +OPENMETEO_FALLBACK_URL=https://api.open-meteo.com +OPENMETEO_FALLBACK_ON_PARTIAL=true # News RSS (comma-separated) NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss diff --git a/backend/app/api/routes/homelab.py b/backend/app/api/routes/homelab.py index 3e881e2..1fcc650 100644 --- a/backend/app/api/routes/homelab.py +++ b/backend/app/api/routes/homelab.py @@ -1,8 +1,11 @@ import httpx -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from app.auth.deps import get_current_user from app.config import get_settings +from app.db.models import User from app.homelab.comfyui import _use_anima +from app.homelab.openmeteo import build_weather_dashboard router = APIRouter(prefix="/homelab", tags=["homelab"]) @@ -40,3 +43,12 @@ def homelab_status() -> dict: "rp_chat_enabled": settings.rp_chat_enabled, }, } + + +@router.get("/weather") +def weather_dashboard( + hours_ahead: int = 12, + _: User = Depends(get_current_user), +) -> dict: + hours = max(1, min(int(hours_ahead), 48)) + return build_weather_dashboard(hours_ahead=hours) diff --git a/backend/app/character/card.py b/backend/app/character/card.py index 2a4415c..2543730 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -23,7 +23,7 @@ TOOLS_INSTRUCTIONS = """ - В текстовых ответах пользователю не используй эмодзи. - Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай. - Утренний брифинг (погода + новости) → get_morning_briefing. -- Картинки: generate_image — «нарисуй себя» → draw_self=true (портрет по appearance_tags, без LLM); иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй. +- Картинки: generate_image — draw_self=true + scene_description (full_body, outfit…); appearance только из карточки. Не злоупотребляй. - Покупки: 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. diff --git a/backend/app/config.py b/backend/app/config.py index d559c37..1a46988 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -63,6 +63,8 @@ class Settings(BaseSettings): weather_lon: float = 30.3351 weather_location_name: str = "Санкт-Петербург" weather_cache_sec: int = 300 + openmeteo_fallback_url: str = "https://api.open-meteo.com" + openmeteo_fallback_on_partial: bool = True news_rss_urls: str = ( "https://habr.com/ru/rss/all/all/," diff --git a/backend/app/homelab/anima_prompt.py b/backend/app/homelab/anima_prompt.py index f9ad95a..2133cb1 100644 --- a/backend/app/homelab/anima_prompt.py +++ b/backend/app/homelab/anima_prompt.py @@ -1,13 +1,17 @@ -"""Сборка Anima-промптов без LLM (теги, без POV/hybrid).""" +"""Сборка Anima-промптов: appearance из карточки + action/outfit из контекста.""" 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" +_INVALID_TAGS = frozenset({ + "pumped_up", "pumped", "looking_at_each_other", "couple", + "2girls", "2boys", "multiple_girls", "multiple_boys", +}) + _JUNK_STANDALONE_TAGS = frozenset({ "white", "black", "skin", "ear", "ears", "girl", "boy", "fox", "wolf", "cat", "short", "tall", "golden", "silver", "red", "blue", "green", "purple", @@ -33,6 +37,8 @@ def _sanitize_tags(tag_str: str) -> str: key = t.lower().replace(" ", "_") if key in seen or len(key) <= 2: continue + if key in _INVALID_TAGS: + continue if "_" not in key and key in _JUNK_STANDALONE_TAGS: continue seen.add(key) @@ -48,20 +54,26 @@ def _append_lora(parts: list[str], lora_name: str, lora_weight: float) -> None: parts.append(f"") -def build_draw_self_prompt( +def build_character_image_prompt( appearance_tags: str, *, + action_tags: str = "", + outfit_tags: str = "", + environment_tags: str = "", lora_name: str = "", lora_weight: float = 0.8, ) -> AnimaPromptBundle: - """Портрет «нарисуй себя» — только booru-теги, без POV и prose.""" + """Appearance (карточка) + action/outfit/env (контекст), только теги.""" appearance = _sanitize_tags(appearance_tags) - action = "looking_at_viewer, smile, upper_body, portrait" - environment = "simple_background, soft_lighting" + outfit = _sanitize_tags(outfit_tags) + action = _sanitize_tags(action_tags) or "looking_at_viewer, smile" + environment = _sanitize_tags(environment_tags) or "simple_background, soft_lighting" parts = [ANIMA_QUALITY] if appearance: parts.append(appearance) + if outfit: + parts.append(outfit) parts.append(action) parts.append(environment) _append_lora(parts, lora_name, lora_weight) @@ -70,6 +82,25 @@ def build_draw_self_prompt( return AnimaPromptBundle(positive=positive, negative=ANIMA_NEGATIVE) +def build_draw_self_prompt( + appearance_tags: str, + *, + action_tags: str = "", + outfit_tags: str = "", + environment_tags: str = "", + lora_name: str = "", + lora_weight: float = 0.8, +) -> AnimaPromptBundle: + return build_character_image_prompt( + appearance_tags, + action_tags=action_tags, + outfit_tags=outfit_tags, + environment_tags=environment_tags, + lora_name=lora_name, + lora_weight=lora_weight, + ) + + def build_scene_tags_prompt( scene_tags: str, appearance_tags: str, @@ -77,24 +108,13 @@ def build_scene_tags_prompt( lora_name: str = "", lora_weight: float = 0.8, ) -> AnimaPromptBundle: - """Прямая сцена из booru-тегов (без LLM).""" - appearance = _sanitize_tags(appearance_tags) + """Готовые booru-теги сцены + appearance.""" 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)) + return build_character_image_prompt( + appearance_tags, + action_tags=scene, + outfit_tags="", + environment_tags="simple_background, soft_lighting", + lora_name=lora_name, + lora_weight=lora_weight, + ) diff --git a/backend/app/homelab/image_gen.py b/backend/app/homelab/image_gen.py index 9b9f457..36ca6ff 100644 --- a/backend/app/homelab/image_gen.py +++ b/backend/app/homelab/image_gen.py @@ -1,190 +1,251 @@ -from typing import Any - -from sqlalchemy.orm import Session - -from app.character.service import CharacterService -from app.config import get_settings -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 - - -def _card_image_settings(db: Session, user_id: int) -> dict[str, Any]: - return CharacterService(db, user_id).get_card().get("data", {}) - - -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( - Message.session_id == session_id, - Message.role.in_(("user", "assistant")), - ) - .order_by(Message.created_at.desc()) - .limit(limit) - ).all() - rows = list(reversed(rows)) - return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()] - - -def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str: - if not lora_name or f"" - - -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, - *, - user_id: int, - session_id: int | None = None, - draw_self: bool = False, - scene_description: str = "", -) -> dict[str, Any]: - card = _card_image_settings(db, user_id) - settings = get_settings() - - if not card.get("sd_enabled", True): - return {"ok": False, "error": "Генерация изображений отключена в настройках персонажа"} - - if not draw_self and not scene_description.strip(): - return {"ok": False, "error": "Нужен draw_self=true или scene_description"} - - appearance = (card.get("appearance_tags") or "").strip() - 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) - messages = messages + [{"role": "user", "content": scene}] - - if settings.rp_chat_enabled: - return await _generate_via_rp_chat( - card, - messages, - appearance_override=appearance or None, - ) - - fallback = f"{appearance}, {scene}" if appearance else scene - return await ComfyUIClient().generate_image(fallback) - - -async def _generate_via_rp_chat( - card: dict[str, Any], - messages: list[dict[str, str]], - appearance_override: str | None, -) -> dict[str, Any]: - client = RpChatClient() - persona_id = (card.get("rp_persona_id") or "").strip() or "default" - override = appearance_override or (card.get("appearance_tags") or "").strip() or None - - prompt_result = await client.sd_prompt( - persona_id, - messages, - appearance_override=override, - ) - if not prompt_result.get("ok"): - return prompt_result - - positive = ( - prompt_result.get("hybrid_positive") - or prompt_result.get("tag_positive") - or "" - ).strip() - negative = (prompt_result.get("negative") or "").strip() - if not positive: - return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result} - - lora = (card.get("lora_name") or "").strip() - if lora: - weight = float(card.get("lora_weight") or 0.8) - positive = _append_lora(positive, lora, weight) - - gen_result = await client.generate(positive, 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": positive, - "negative_prompt": negative, - "backend": "rp_chat", - "persona_id": persona_id, - "prompt_mode": "llm", - } +from typing import Any + +from sqlalchemy.orm import Session + +from app.character.service import CharacterService +from app.config import get_settings +from app.homelab.anima_prompt import AnimaPromptBundle, build_character_image_prompt, build_scene_tags_prompt +from app.homelab.comfyui import ComfyUIClient +from app.homelab.scene_tags import extract_scene_tags, looks_like_booru_tags +from app.integrations.rp_chat import RpChatClient + + +def _card_image_settings(db: Session, user_id: int) -> dict[str, Any]: + return CharacterService(db, user_id).get_card().get("data", {}) + + +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( + Message.session_id == session_id, + Message.role.in_(("user", "assistant")), + ) + .order_by(Message.created_at.desc()) + .limit(limit) + ).all() + rows = list(reversed(rows)) + return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()] + + +def _last_user_message(messages: list[dict[str, str]]) -> str: + for msg in reversed(messages): + if msg.get("role") == "user" and (msg.get("content") or "").strip(): + return str(msg["content"]).strip() + return "" + + +def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str: + if not lora_name or f"" + + +async def _generate_from_bundle( + bundle: AnimaPromptBundle, + *, + backend: str, + persona_id: str = "", + prompt_mode: str = "direct", + tag_source: str = "", +) -> dict[str, Any]: + 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": prompt_mode, + "tag_source": tag_source, + } + + result = await ComfyUIClient().generate_image( + bundle.positive, + negative_prompt=bundle.negative, + ) + if result.get("ok"): + result["backend"] = "comfyui_local" + result["prompt_mode"] = prompt_mode + result["negative_prompt"] = bundle.negative + result["tag_source"] = tag_source + return result + + +async def _build_contextual_bundle( + appearance: str, + *, + request: str, + messages: list[dict[str, str]], + lora_name: str, + lora_weight: float, +) -> tuple[AnimaPromptBundle, str]: + tags = await extract_scene_tags(request, messages, appearance_tags=appearance) + bundle = build_character_image_prompt( + appearance, + action_tags=tags.get("action_tags", ""), + outfit_tags=tags.get("outfit_tags", ""), + environment_tags=tags.get("environment_tags", ""), + lora_name=lora_name, + lora_weight=lora_weight, + ) + return bundle, str(tags.get("source") or "") + + +async def generate_image( + db: Session, + *, + user_id: int, + session_id: int | None = None, + draw_self: bool = False, + scene_description: str = "", +) -> dict[str, Any]: + card = _card_image_settings(db, user_id) + settings = get_settings() + + if not card.get("sd_enabled", True): + return {"ok": False, "error": "Генерация изображений отключена в настройках персонажа"} + + if not draw_self and not scene_description.strip(): + return {"ok": False, "error": "Нужен draw_self=true или scene_description"} + + appearance = (card.get("appearance_tags") or "").strip() + 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" + messages = _session_messages(db, session_id) + + if draw_self: + if not appearance: + return { + "ok": False, + "error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»", + } + request = scene_description.strip() or _last_user_message(messages) or "portrait" + bundle, tag_source = await _build_contextual_bundle( + appearance, + request=request, + messages=messages, + lora_name=lora_name, + lora_weight=lora_weight, + ) + return await _generate_from_bundle( + bundle, + backend=backend, + persona_id=persona_id, + prompt_mode="context_tags", + tag_source=tag_source, + ) + + scene = scene_description.strip() + if looks_like_booru_tags(scene): + 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, + prompt_mode="booru_literal", + tag_source="booru_literal", + ) + + request = scene or _last_user_message(messages) + if appearance and request: + bundle, tag_source = await _build_contextual_bundle( + appearance, + request=request, + messages=messages, + lora_name=lora_name, + lora_weight=lora_weight, + ) + return await _generate_from_bundle( + bundle, + backend=backend, + persona_id=persona_id, + prompt_mode="context_tags", + tag_source=tag_source, + ) + + messages = messages + [{"role": "user", "content": scene}] + + if settings.rp_chat_enabled: + return await _generate_via_rp_chat( + card, + messages, + appearance_override=appearance or None, + ) + + fallback = f"{appearance}, {scene}" if appearance else scene + return await ComfyUIClient().generate_image(fallback) + + +async def _generate_via_rp_chat( + card: dict[str, Any], + messages: list[dict[str, str]], + appearance_override: str | None, +) -> dict[str, Any]: + client = RpChatClient() + persona_id = (card.get("rp_persona_id") or "").strip() or "default" + override = appearance_override or (card.get("appearance_tags") or "").strip() or None + + prompt_result = await client.sd_prompt( + persona_id, + messages, + appearance_override=override, + ) + if not prompt_result.get("ok"): + return prompt_result + + positive = ( + prompt_result.get("hybrid_positive") + or prompt_result.get("tag_positive") + or "" + ).strip() + negative = (prompt_result.get("negative") or "").strip() + if not positive: + return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result} + + lora = (card.get("lora_name") or "").strip() + if lora: + weight = float(card.get("lora_weight") or 0.8) + positive = _append_lora(positive, lora, weight) + + gen_result = await client.generate(positive, 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": positive, + "negative_prompt": negative, + "backend": "rp_chat", + "persona_id": persona_id, + "prompt_mode": "llm", + } + \ No newline at end of file diff --git a/backend/app/homelab/openmeteo.py b/backend/app/homelab/openmeteo.py index 6c35755..a7ecb48 100644 --- a/backend/app/homelab/openmeteo.py +++ b/backend/app/homelab/openmeteo.py @@ -29,42 +29,172 @@ WEATHER_CODES: dict[int, str] = { 99: "гроза с градом", } -_cache: dict[str, Any] = {"data": None, "expires_at": 0.0} +_cache: dict[str, Any] = { + "data": None, + "fetched_at": 0.0, + "expires_at": 0.0, + "source": "local", + "local_coverage": {"current": [], "hourly": []}, +} + +CURRENT_FIELDS = ( + "temperature_2m", + "apparent_temperature", + "relative_humidity_2m", + "precipitation", + "weather_code", + "wind_speed_10m", +) +HOURLY_FIELDS = ( + "temperature_2m", + "precipitation_probability", + "precipitation", + "weather_code", +) + +RECOMMENDED_SYNC_DOMAINS = "dwd_icon,ncep_gfs013,ncep_gefs025" +RECOMMENDED_SYNC_VARIABLES = ( + "temperature_2m,dew_point_2m,relative_humidity_2m,precipitation_probability," + "precipitation,rain,cloud_cover,weather_code,wind_u_component_10m,wind_v_component_10m" +) +SYNC_HINT = ( + "Контейнер open-meteo-sync, скорее всего, качает только temperature_2m. " + f"Задай SYNC_DOMAINS={RECOMMENDED_SYNC_DOMAINS} и " + f"SYNC_VARIABLES={RECOMMENDED_SYNC_VARIABLES} (~12 GB). " + "Документация: github.com/open-meteo/open-data/tree/main/tutorial_weather_api" +) + + +def _hourly_series(hourly: dict[str, Any], key: str) -> list[Any]: + values = hourly.get(key) + return values if isinstance(values, list) else [] + + +def _hourly_start_index(times: list[str], anchor_time: str | None) -> int: + if not times: + return 0 + if not anchor_time: + return 0 + best = 0 + for i, t in enumerate(times): + if t <= anchor_time: + best = i + else: + break + return best + + +def _field_coverage(raw: dict[str, Any]) -> dict[str, list[str]]: + """Какие поля реально пришли от OpenMeteo (не null).""" + current = raw.get("current") or {} + hourly = raw.get("hourly") or {} + current_present = [ + key for key in CURRENT_FIELDS if current.get(key) is not None + ] + hourly_present = [] + for key in HOURLY_FIELDS: + series = _hourly_series(hourly, key) + if any(v is not None for v in series): + hourly_present.append(key) + return {"current": current_present, "hourly": hourly_present} + + +def _coverage_sufficient(coverage: dict[str, list[str]]) -> bool: + current = set(coverage.get("current") or []) + hourly = set(coverage.get("hourly") or []) + if "weather_code" not in current: + return False + if len(current) < 3: + return False + if "precipitation_probability" not in hourly and "weather_code" not in hourly: + return False + return True + + +def _fmt_num(value: Any, *, suffix: str = "") -> str: + if value is None: + return "—" + if isinstance(value, float): + text = f"{value:.1f}".rstrip("0").rstrip(".") + else: + text = str(value) + return f"{text}{suffix}" if suffix else text class OpenMeteoClient: def __init__(self) -> None: settings = get_settings() self.base_url = settings.openmeteo_base_url.rstrip("/") + self.fallback_url = (settings.openmeteo_fallback_url or "").strip().rstrip("/") + self.fallback_on_partial = settings.openmeteo_fallback_on_partial self.lat = settings.weather_lat self.lon = settings.weather_lon self.location_name = settings.weather_location_name self.cache_ttl = settings.weather_cache_sec + def _request_params(self) -> dict[str, Any]: + return { + "latitude": self.lat, + "longitude": self.lon, + "current": ",".join(CURRENT_FIELDS), + "hourly": ",".join(HOURLY_FIELDS), + "timezone": "auto", + "forecast_days": 2, + } + + def _fetch_from_url(self, base_url: str) -> dict[str, Any]: + with httpx.Client(timeout=20.0) as client: + response = client.get(f"{base_url.rstrip('/')}/v1/forecast", params=self._request_params()) + response.raise_for_status() + return response.json() + def _fetch_raw(self) -> dict[str, Any]: now = time.time() if _cache["data"] and now < _cache["expires_at"]: return _cache["data"] - params = { - "latitude": self.lat, - "longitude": self.lon, - "current": ( - "temperature_2m,apparent_temperature,relative_humidity_2m," - "precipitation,weather_code,wind_speed_10m" - ), - "hourly": "temperature_2m,precipitation_probability,precipitation,weather_code", - "timezone": "auto", - "forecast_days": 2, - } - with httpx.Client(timeout=15.0) as client: - response = client.get(f"{self.base_url}/v1/forecast", params=params) - response.raise_for_status() - data = response.json() + local_raw = self._fetch_from_url(self.base_url) + local_coverage = _field_coverage(local_raw) + source = "local" + raw = local_raw - _cache["data"] = data + if ( + self.fallback_on_partial + and self.fallback_url + and self.fallback_url.rstrip("/") != self.base_url + and not _coverage_sufficient(local_coverage) + ): + try: + fallback_raw = self._fetch_from_url(self.fallback_url) + if _coverage_sufficient(_field_coverage(fallback_raw)): + raw = fallback_raw + source = "fallback" + except Exception: + pass + + _cache["data"] = raw + _cache["fetched_at"] = now _cache["expires_at"] = now + self.cache_ttl - return data + _cache["source"] = source + _cache["local_coverage"] = local_coverage + return raw + + def cache_status(self) -> dict[str, Any]: + now = time.time() + fetched_at = float(_cache.get("fetched_at") or 0) + expires_at = float(_cache.get("expires_at") or 0) + has_data = _cache.get("data") is not None + age_sec = int(now - fetched_at) if fetched_at else None + expires_in_sec = max(0, int(expires_at - now)) if expires_at else None + return { + "has_data": has_data, + "cached": bool(has_data and expires_at and now < expires_at), + "fetched_at": fetched_at or None, + "age_sec": age_sec, + "ttl_sec": self.cache_ttl, + "expires_in_sec": expires_in_sec, + "source": _cache.get("source") or "local", + } def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]: try: @@ -75,21 +205,32 @@ class OpenMeteoClient: current = raw.get("current") or {} hourly = raw.get("hourly") or {} times = hourly.get("time") or [] - limit = min(hours_ahead, len(times)) + start = _hourly_start_index(times, current.get("time")) + end = min(start + hours_ahead, len(times)) hourly_slice = [] - for i in range(limit): + for i in range(start, end): + code = _hourly_series(hourly, "weather_code")[i] if i < len(_hourly_series(hourly, "weather_code")) else None + temp_series = _hourly_series(hourly, "temperature_2m") + precip_series = _hourly_series(hourly, "precipitation") + prob_series = _hourly_series(hourly, "precipitation_probability") hourly_slice.append({ "time": times[i], - "temperature_c": hourly.get("temperature_2m", [None])[i], - "precipitation_mm": hourly.get("precipitation", [None])[i], - "precipitation_probability": hourly.get("precipitation_probability", [None])[i], - "weather_code": hourly.get("weather_code", [None])[i], + "temperature_c": temp_series[i] if i < len(temp_series) else None, + "precipitation_mm": precip_series[i] if i < len(precip_series) else None, + "precipitation_probability": prob_series[i] if i < len(prob_series) else None, + "weather_code": code, + "conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно", }) code = current.get("weather_code") + coverage = _field_coverage(raw) return { "ok": True, "location": self.location_name, + "data_source": _cache.get("source") or "local", + "local_field_coverage": _cache.get("local_coverage") or coverage, + "field_coverage": coverage, + "sync_hint": SYNC_HINT if not _coverage_sufficient(_cache.get("local_coverage") or coverage) else "", "current": { "time": current.get("time"), "temperature_c": current.get("temperature_2m"), @@ -132,10 +273,13 @@ def format_weather_snapshot(data: dict[str, Any] | None = None) -> str: return "\n".join(lines) cur = snapshot.get("current") or {} + apparent = cur.get("apparent_temperature_c") + wind = cur.get("wind_speed_kmh") + apparent_part = f", ощущается {_fmt_num(apparent, suffix='°C')}" if apparent is not None else "" + wind_part = f", ветер {_fmt_num(wind, suffix=' км/ч')}" if wind is not None else "" lines.append( - f"{snapshot.get('location')}: {cur.get('temperature_c')}°C " - f"(ощущается {cur.get('apparent_temperature_c')}°C), " - f"{cur.get('conditions')}, ветер {cur.get('wind_speed_kmh')} км/ч." + f"{snapshot.get('location')}: {_fmt_num(cur.get('temperature_c'), suffix='°C')}" + f"{apparent_part}, {cur.get('conditions') or 'неизвестно'}{wind_part}." ) hourly = snapshot.get("hourly") or [] rainy_hours = [] @@ -151,3 +295,42 @@ def format_weather_snapshot(data: dict[str, Any] | None = None) -> str: lines.append("Существенных осадков в ближайшие часы не ожидается.") lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.") return "\n".join(lines) + + +def build_weather_dashboard(hours_ahead: int = 12) -> dict[str, Any]: + """Полный снимок для UI: данные OpenMeteo + контекст ассистента.""" + client = OpenMeteoClient() + weather = client.fetch_current_and_hourly(hours_ahead=hours_ahead) + settings = get_settings() + return { + "weather": weather, + "rain_summary": client.rain_summary(hours_ahead=hours_ahead) if weather.get("ok") else "", + "assistant_context": format_weather_snapshot(weather), + "cache": client.cache_status(), + "config": { + "location": client.location_name, + "latitude": client.lat, + "longitude": client.lon, + "openmeteo_base_url": client.base_url, + "cache_ttl_sec": client.cache_ttl, + "forecast_days": 2, + "timezone": "auto", + }, + "available_fields": { + "current": list(CURRENT_FIELDS), + "hourly": list(HOURLY_FIELDS), + }, + "field_coverage": weather.get("field_coverage") if weather.get("ok") else {"current": [], "hourly": []}, + "local_field_coverage": weather.get("local_field_coverage") if weather.get("ok") else {"current": [], "hourly": []}, + "data_source": weather.get("data_source", "local") if weather.get("ok") else "local", + "sync_hint": weather.get("sync_hint", "") if weather.get("ok") else SYNC_HINT, + "recommended_sync": { + "domains": RECOMMENDED_SYNC_DOMAINS, + "variables": RECOMMENDED_SYNC_VARIABLES, + }, + "assistant_tools": { + "get_weather": "Текущая погода и почасовой прогнос (hours_ahead до 48)", + "get_morning_briefing": "Погода + заголовки RSS-новостей", + }, + "system_prompt": "Краткий блок [Погода] в system prompt каждого сообщения (6 ч почасово).", + } diff --git a/backend/app/homelab/scene_tags.py b/backend/app/homelab/scene_tags.py new file mode 100644 index 0000000..c578dba --- /dev/null +++ b/backend/app/homelab/scene_tags.py @@ -0,0 +1,189 @@ +"""Извлечение action/outfit/environment в danbooru-теги из запроса и чата.""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +from app.config import get_settings +from app.llm.client import LLMClient +from app.projects.structuring import strip_markdown_json + +logger = logging.getLogger(__name__) + +SCENE_TAGS_PROMPT = """ +Ты переводишь запрос на иллюстрацию персонажа в теги Stable Diffusion (danbooru/e621). +Ответь ТОЛЬКО JSON без markdown: +{ + "action_tags": "pose, framing, expression, activity — 3-10 тегов через запятую", + "outfit_tags": "одежда и аксессуары или пустая строка", + "environment_tags": "локация, освещение, время суток — 2-6 тегов или пустая строка" +} + +Правила: +- Только настоящие booru-теги. Пробелы в тегах → underscore (full_body, looking_at_viewer). +- НЕ дублируй внешность персонажа (волосы, глаза, уши, хвост, телосложение) — они уже в appearance_tags. +- НЕ включай quality-теги, 1girl, имена моделей. +- «полный рост» / full body → full_body, standing (НЕ upper_body, НЕ portrait). +- «портрет» / крупный план → upper_body, portrait или close-up. +- Одежду бери из запроса и контекста чата (фартук, платье, домашняя одежда → соответствующие теги). +- Если фон не указан — simple_background, soft_lighting. +- Запрещённые теги: pumped_up, looking_at_each_other, couple, 2girls. +""".strip() + + +def _chat_excerpt(messages: list[dict[str, str]], limit: int = 6) -> str: + lines: list[str] = [] + for msg in messages[-limit:]: + role = msg.get("role", "user") + content = (msg.get("content") or "").strip() + if not content or role not in ("user", "assistant"): + continue + label = "Пользователь" if role == "user" else "Персонаж" + if len(content) > 600: + content = content[:597] + "..." + lines.append(f"{label}: {content}") + return "\n".join(lines) + + +def rule_based_scene_tags(request: str, messages: list[dict[str, str]] | None = None) -> dict[str, str]: + """Быстрый fallback без LLM.""" + blob = " ".join( + [ + request or "", + _chat_excerpt(messages or [], limit=4), + ] + ).lower() + + action: list[str] = [] + if re.search(r"полный\s+рост|full[\s_-]?body|в\s+полный\s+рост|целиком|head\s+to\s+toe", blob): + action.extend(["full_body", "standing", "looking_at_viewer"]) + elif re.search(r"портрет|portrait|крупн|upper[\s_-]?body|бust|бюст", blob): + action.extend(["upper_body", "portrait", "looking_at_viewer"]) + elif re.search(r"сидит|sitting|на стуле", blob): + action.extend(["sitting", "looking_at_viewer"]) + elif re.search(r"лежит|lying|на кровати", blob): + action.extend(["lying", "on_bed", "looking_at_viewer"]) + else: + action.extend(["looking_at_viewer", "smile"]) + + if re.search(r"смущ|embarrass|blush|стесн", blob): + action.append("blush") + if re.search(r"улыб|smile|happy", blob): + action.append("smile") + + outfit: list[str] = [] + outfit_map = ( + (r"фартук|apron", "apron"), + (r"плать|dress", "dress"), + (r"халат|robe|bathrobe", "robe"), + (r"купальник|swimsuit|bikini", "swimsuit"), + (r"школьн|school uniform|serafuku", "school_uniform"), + (r"обнаж|nude|голая|topless", "nude"), + (r"джинс|jeans", "jeans"), + (r"свитер|sweater", "sweater"), + ) + for pattern, tag in outfit_map: + if re.search(pattern, blob): + outfit.append(tag) + + env: list[str] = [] + if re.search(r"комнат|bedroom|дома|indoors|room", blob): + env.extend(["indoors", "soft_lighting"]) + elif re.search(r"улиц|outdoors|street|парк|park", blob): + env.extend(["outdoors", "daylight"]) + else: + env.extend(["simple_background", "soft_lighting"]) + + return { + "action_tags": ", ".join(dict.fromkeys(action)), + "outfit_tags": ", ".join(dict.fromkeys(outfit)), + "environment_tags": ", ".join(dict.fromkeys(env)), + } + + +def _parse_tags_json(raw: str) -> dict[str, str] | None: + try: + data = json.loads(strip_markdown_json(raw)) + except json.JSONDecodeError: + return None + if not isinstance(data, dict): + return None + return { + "action_tags": str(data.get("action_tags") or "").strip(), + "outfit_tags": str(data.get("outfit_tags") or "").strip(), + "environment_tags": str(data.get("environment_tags") or "").strip(), + } + + +async def extract_scene_tags( + request: str, + messages: list[dict[str, str]] | None = None, + *, + appearance_tags: str = "", +) -> dict[str, Any]: + """ + action/outfit/environment в booru-тегах. + Возвращает dict с полями action_tags, outfit_tags, environment_tags, source. + """ + req = (request or "").strip() + if not req and messages: + for msg in reversed(messages): + if msg.get("role") == "user" and (msg.get("content") or "").strip(): + req = str(msg["content"]).strip() + break + + if looks_like_booru_tags(req): + parts = [p.strip() for p in req.split(",") if p.strip()] + return { + "action_tags": ", ".join(parts), + "outfit_tags": "", + "environment_tags": "simple_background, soft_lighting", + "source": "booru_literal", + } + + fallback = rule_based_scene_tags(req, messages) + + settings = get_settings() + extract_model = settings.memory_extract_model.strip() or None + excerpt = _chat_excerpt(messages or []) + + user_block = f"Запрос на иллюстрацию:\n{req or '(не указан — выведи нейтральную позу)'}" + if appearance_tags.strip(): + user_block += f"\n\nAppearance (НЕ повторять в action/outfit): {appearance_tags.strip()}" + if excerpt: + user_block += f"\n\nКонтекст чата:\n{excerpt}" + + try: + llm = LLMClient() + result = await llm.complete( + [ + {"role": "system", "content": SCENE_TAGS_PROMPT}, + {"role": "user", "content": user_block}, + ], + temperature=0.2, + model=extract_model, + for_extraction=True, + ) + parsed = _parse_tags_json(result.get("content") or "") + if parsed and parsed.get("action_tags"): + parsed["source"] = "llm" + if not parsed.get("environment_tags"): + parsed["environment_tags"] = fallback["environment_tags"] + return parsed + except Exception: + logger.exception("scene tag LLM extraction failed") + + fallback["source"] = "rules" + return fallback + + +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)) diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index db8d2c1..1f6b3c9 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -576,21 +576,24 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "function": { "name": "generate_image", "description": ( - "Аниме-картинка (Anima через RP-чат). " - "«Нарисуй себя» / портрет персонажа → draw_self=true. " - "Другая сцена → scene_description на английском (booru-теги). " - "Внешность берётся из карточки персонажа. Только по запросу или когда уместно." + "Аниме-картинка (Anima). draw_self=true — персонаж из карточки; " + "scene_description — поза/кадр/одежда (booru-теги на англ. или короткий запрос: " + "full body, sitting, apron). Можно оба параметра: draw_self + scene_description. " + "Внешность только из appearance_tags карточки." ), "parameters": { "type": "object", "properties": { "draw_self": { "type": "boolean", - "description": "Нарисовать персонажа из карточки в контексте текущего чата", + "description": "Нарисовать персонажа из карточки", }, "scene_description": { "type": "string", - "description": "Описание сцены на английском (booru-теги), если не draw_self", + "description": ( + "Поза, кадр, одежда, обстановка — booru-теги или запрос " + "(full_body, standing, apron, blush). С draw_self=true — уточняет сцену." + ), }, }, "required": [], diff --git a/backend/prompts/assistant.md b/backend/prompts/assistant.md index c34b66a..be02723 100644 --- a/backend/prompts/assistant.md +++ b/backend/prompts/assistant.md @@ -30,6 +30,6 @@ - add_shopping_items, list_shopping_lists, check_shopping_item Картинки: -- «Нарисуй себя» → generate_image с draw_self=true (портрет по appearance_tags, LLM sd-prompt не нужен) -- Другая сцена → generate_image с scene_description на английском (booru-теги; если теги — тоже без LLM) +- «Нарисуй себя» → generate_image draw_self=true; «в полный рост» → scene_description="full_body, standing" +- Другая сцена → scene_description (booru-теги или короткий запрос); draw_self=true если персонаж из карточки - Внешность персонажа задаётся в настройках карточки, не выдумывай теги diff --git a/backend/tests/test_anima_prompt.py b/backend/tests/test_anima_prompt.py index 19afbd6..5dd52dc 100644 --- a/backend/tests/test_anima_prompt.py +++ b/backend/tests/test_anima_prompt.py @@ -1,18 +1,34 @@ from app.homelab.anima_prompt import ( + build_character_image_prompt, build_draw_self_prompt, build_scene_tags_prompt, - looks_like_booru_tags, ) +from app.homelab.scene_tags import extract_scene_tags, looks_like_booru_tags, rule_based_scene_tags -def test_build_draw_self_prompt_includes_appearance(): - bundle = build_draw_self_prompt("silver_hair, wolf_ears, blue_eyes") +def test_build_character_image_prompt_full_body(): + bundle = build_character_image_prompt( + "wolfgirl, white_hair, pumped_up", + action_tags="full_body, standing, looking_at_viewer", + outfit_tags="apron", + environment_tags="indoors, soft_lighting", + ) + assert "full_body" in bundle.positive + assert "standing" in bundle.positive + assert "apron" in bundle.positive + assert "pumped_up" not in bundle.positive + assert "upper_body" not in bundle.positive + assert "portrait" not in bundle.positive + + +def test_build_draw_self_prompt_with_action(): + bundle = build_draw_self_prompt( + "silver_hair, wolf_ears", + action_tags="full_body, standing", + outfit_tags="", + ) + assert "full_body" in bundle.positive 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(): @@ -23,3 +39,14 @@ def test_build_draw_self_prompt_lora(): 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") + + +def test_rule_based_full_body_russian(): + tags = rule_based_scene_tags("Очень, а в полный рост можешь?", []) + assert "full_body" in tags["action_tags"] + assert "portrait" not in tags["action_tags"] + + +def test_rule_based_outfit_apron(): + tags = rule_based_scene_tags("", [{"role": "assistant", "content": "В фартуке стою у плиты"}]) + assert "apron" in tags["outfit_tags"] diff --git a/backend/tests/test_openmeteo_helpers.py b/backend/tests/test_openmeteo_helpers.py new file mode 100644 index 0000000..ace1459 --- /dev/null +++ b/backend/tests/test_openmeteo_helpers.py @@ -0,0 +1,69 @@ +from unittest.mock import patch + +from app.homelab.openmeteo import ( + RECOMMENDED_SYNC_DOMAINS, + RECOMMENDED_SYNC_VARIABLES, + SYNC_HINT, + _coverage_sufficient, + _field_coverage, + _hourly_start_index, + build_weather_dashboard, +) + + +def test_hourly_start_index_from_current(): + times = ["2026-06-14T00:00", "2026-06-14T01:00", "2026-06-14T18:00", "2026-06-14T19:00"] + assert _hourly_start_index(times, "2026-06-14T18:15") == 2 + + +def test_coverage_sufficient(): + assert _coverage_sufficient({"current": ["temperature_2m"], "hourly": ["temperature_2m"]}) is False + assert _coverage_sufficient( + { + "current": ["temperature_2m", "weather_code", "wind_speed_10m"], + "hourly": ["temperature_2m", "precipitation_probability", "weather_code"], + } + ) is True + + +def test_field_coverage_partial(): + raw = { + "current": {"time": "2026-06-14T18:15", "temperature_2m": 20.6}, + "hourly": { + "time": ["2026-06-14T18:00", "2026-06-14T19:00"], + "temperature_2m": [20.0, 19.5], + "precipitation": [0.0, 0.0], + }, + } + coverage = _field_coverage(raw) + assert coverage["current"] == ["temperature_2m"] + assert "temperature_2m" in coverage["hourly"] + assert "precipitation" in coverage["hourly"] + assert "weather_code" not in coverage["hourly"] + + +def test_build_weather_dashboard_includes_sync_hint(): + fake_weather = { + "ok": True, + "location": "Test", + "data_source": "local", + "local_field_coverage": {"current": ["temperature_2m"], "hourly": ["temperature_2m"]}, + "field_coverage": {"current": ["temperature_2m"], "hourly": ["temperature_2m"]}, + "sync_hint": SYNC_HINT, + "current": {"temperature_c": 10, "conditions": "неизвестно"}, + "hourly": [], + } + with patch("app.homelab.openmeteo.OpenMeteoClient") as mock_cls: + client = mock_cls.return_value + client.fetch_current_and_hourly.return_value = fake_weather + client.rain_summary.return_value = "ok" + client.cache_status.return_value = {"source": "local", "has_data": True, "cached": True, "ttl_sec": 300} + client.location_name = "Test" + client.lat = 1.0 + client.lon = 2.0 + client.base_url = "http://local" + client.cache_ttl = 300 + result = build_weather_dashboard() + assert result["sync_hint"] + assert result["recommended_sync"]["domains"] == RECOMMENDED_SYNC_DOMAINS + assert result["recommended_sync"]["variables"] == RECOMMENDED_SYNC_VARIABLES diff --git a/backend/tests/test_scene_tags.py b/backend/tests/test_scene_tags.py new file mode 100644 index 0000000..86d6227 --- /dev/null +++ b/backend/tests/test_scene_tags.py @@ -0,0 +1,16 @@ +import pytest + +from app.homelab.scene_tags import rule_based_scene_tags + + +@pytest.mark.parametrize( + "user_request,expected", + [ + ("full body please", "full_body"), + ("нарисуй в полный рост", "full_body"), + ("portrait close up", "upper_body"), + ], +) +def test_rule_based_framing(user_request: str, expected: str): + tags = rule_based_scene_tags(user_request, []) + assert expected in tags["action_tags"] diff --git a/backend/tests/test_weather_dashboard.py b/backend/tests/test_weather_dashboard.py new file mode 100644 index 0000000..bfc07bf --- /dev/null +++ b/backend/tests/test_weather_dashboard.py @@ -0,0 +1,57 @@ +from unittest.mock import patch + +from app.homelab.openmeteo import build_weather_dashboard + + +def test_build_weather_dashboard_structure(): + fake_weather = { + "ok": True, + "location": "Test City", + "current": { + "time": "2026-06-13T12:00", + "temperature_c": 18.5, + "apparent_temperature_c": 17.0, + "humidity_pct": 55, + "precipitation_mm": 0.0, + "wind_speed_kmh": 12.0, + "weather_code": 2, + "conditions": "переменная облачность", + }, + "hourly": [ + { + "time": "2026-06-13T12:00", + "temperature_c": 18.5, + "precipitation_mm": 0.0, + "precipitation_probability": 10, + "weather_code": 2, + "conditions": "переменная облачность", + } + ], + } + + with patch("app.homelab.openmeteo.OpenMeteoClient") as mock_cls: + client = mock_cls.return_value + client.fetch_current_and_hourly.return_value = fake_weather + client.rain_summary.return_value = "Существенных осадков в ближайшие часы не ожидается." + client.cache_status.return_value = { + "has_data": True, + "cached": True, + "fetched_at": 1.0, + "age_sec": 10, + "ttl_sec": 300, + "expires_in_sec": 290, + } + client.location_name = "Test City" + client.lat = 59.9 + client.lon = 30.3 + client.base_url = "http://openmeteo.test" + client.cache_ttl = 300 + + result = build_weather_dashboard(hours_ahead=6) + + assert result["weather"]["ok"] is True + assert "[Погода]" in result["assistant_context"] + assert "None" not in result["assistant_context"] + assert "temperature_2m" in result["available_fields"]["current"] + assert "get_weather" in result["assistant_tools"] + assert result["config"]["location"] == "Test City" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a991eb8..834d161 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { NavLink, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import PomodoroWidget from "./components/PomodoroWidget"; +import WeatherWidget from "./components/WeatherWidget"; import RequireAuth from "./components/RequireAuth"; @@ -76,6 +77,7 @@ function AppShell() { Календарь + {user && ( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 97d1b73..dbddad6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -147,6 +147,73 @@ export interface PomodoroStatus { cycle: PomodoroCycle; } +export interface WeatherCurrent { + time?: string | null; + temperature_c?: number | null; + apparent_temperature_c?: number | null; + humidity_pct?: number | null; + precipitation_mm?: number | null; + wind_speed_kmh?: number | null; + weather_code?: number | null; + conditions?: string; +} + +export interface WeatherHourly { + time?: string; + temperature_c?: number | null; + precipitation_mm?: number | null; + precipitation_probability?: number | null; + weather_code?: number | null; + conditions?: string; +} + +export interface WeatherSnapshot { + ok: boolean; + location?: string; + error?: string; + field_coverage?: { current: string[]; hourly: string[] }; + local_field_coverage?: { current: string[]; hourly: string[] }; + data_source?: string; + sync_hint?: string; + current?: WeatherCurrent; + hourly?: WeatherHourly[]; +} + +export interface WeatherDashboard { + weather: WeatherSnapshot; + rain_summary: string; + assistant_context: string; + cache: { + has_data: boolean; + cached: boolean; + fetched_at: number | null; + age_sec: number | null; + ttl_sec: number; + expires_in_sec: number | null; + source?: string; + }; + config: { + location: string; + latitude: number; + longitude: number; + openmeteo_base_url: string; + cache_ttl_sec: number; + forecast_days: number; + timezone: string; + }; + available_fields: { + current: string[]; + hourly: string[]; + }; + field_coverage: { current: string[]; hourly: string[] }; + local_field_coverage: { current: string[]; hourly: string[] }; + data_source: string; + sync_hint: string; + recommended_sync: { domains: string; variables: string }; + assistant_tools: Record; + system_prompt: string; +} + export interface CharacterCardData { name: string; description: string; @@ -567,6 +634,9 @@ export const api = { { method: "POST" } ), + weatherDashboard: (hoursAhead = 12) => + request(`/api/v1/homelab/weather?hours_ahead=${hoursAhead}`), + getCharacter: () => request("/api/v1/character"), saveCharacter: (card: CharacterCardV2) => diff --git a/frontend/src/components/WeatherWidget.css b/frontend/src/components/WeatherWidget.css new file mode 100644 index 0000000..1d157a8 --- /dev/null +++ b/frontend/src/components/WeatherWidget.css @@ -0,0 +1,219 @@ +.weather-widget { + position: relative; + flex-shrink: 0; +} + +.weather-widget-trigger { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.55rem; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: #c5ccd6; + cursor: pointer; + font: inherit; + max-width: 220px; +} + +.weather-widget.compact .weather-widget-trigger { + padding: 0.35rem 0.5rem; +} + +.weather-widget-trigger:hover, +.weather-widget.open .weather-widget-trigger { + background: #2b3445; + border-color: #3a4254; +} + +.weather-widget-icon { + font-size: 0.95rem; + line-height: 1; +} + +.weather-widget-summary { + font-size: 0.78rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.weather-widget-panel { + position: absolute; + top: calc(100% + 0.45rem); + right: 0; + z-index: 40; + width: min(420px, calc(100vw - 1.5rem)); + max-height: min(70vh, 640px); + overflow: auto; + padding: 0.85rem; + border-radius: 12px; + border: 1px solid #2a3142; + background: #151922; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} + +.weather-widget-panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.weather-widget-panel-head strong { + display: block; + font-size: 0.95rem; +} + +.weather-widget-sub { + display: block; + margin-top: 0.2rem; + font-size: 0.72rem; + color: #8b95a5; +} + +.weather-widget-refresh { + padding: 0.25rem 0.45rem; + border: 1px solid #3a4254; + border-radius: 6px; + background: #1b2130; + color: #c5ccd6; + cursor: pointer; +} + +.weather-widget-refresh:disabled { + opacity: 0.6; + cursor: default; +} + +.weather-widget-section + .weather-widget-section { + margin-top: 0.85rem; + padding-top: 0.85rem; + border-top: 1px solid #2a2f3a; +} + +.weather-widget-section h4 { + margin: 0 0 0.5rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8b95a5; +} + +.weather-widget-dl { + display: grid; + grid-template-columns: 1fr; + gap: 0.45rem; + margin: 0; +} + +.weather-widget-dl div { + display: grid; + grid-template-columns: 120px 1fr; + gap: 0.5rem; + font-size: 0.8rem; +} + +.weather-widget-dl dt { + margin: 0; + color: #8b95a5; +} + +.weather-widget-dl dd { + margin: 0; + color: #e8ebf0; + word-break: break-word; +} + +.weather-widget-dl.compact-dl div { + grid-template-columns: 110px 1fr; +} + +.weather-widget-note { + margin: 0; + font-size: 0.82rem; + color: #c5ccd6; + line-height: 1.45; +} + +.weather-widget-context { + margin: 0.5rem 0 0; + padding: 0.65rem 0.75rem; + border-radius: 8px; + background: #0f1218; + border: 1px solid #2a3142; + font-size: 0.75rem; + line-height: 1.45; + white-space: pre-wrap; + color: #b8c0cc; +} + +.weather-widget-table-wrap { + overflow-x: auto; +} + +.weather-widget-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +.weather-widget-table th, +.weather-widget-table td { + padding: 0.35rem 0.4rem; + border-bottom: 1px solid #2a2f3a; + text-align: left; + white-space: nowrap; +} + +.weather-widget-table th { + color: #8b95a5; + font-weight: 600; +} + +.weather-widget-tools { + margin: 0.5rem 0 0; + padding-left: 1.1rem; + font-size: 0.78rem; + color: #a8b0bd; +} + +.weather-widget-tools code { + color: #9ec5ff; +} + +.weather-widget-error { + margin: 0 0 0.75rem; + font-size: 0.82rem; + color: #ff8a8a; +} + +.weather-widget-warn { + margin: 0 0 0.75rem; + padding: 0.55rem 0.65rem; + border-radius: 8px; + background: rgba(255, 180, 80, 0.08); + border: 1px solid rgba(255, 180, 80, 0.25); + font-size: 0.78rem; + color: #e8c98a; + line-height: 1.45; +} + +@media (max-width: 768px) { + .weather-widget-panel { + position: fixed; + top: auto; + bottom: 0; + left: 0; + right: 0; + width: auto; + max-height: 75vh; + border-radius: 12px 12px 0 0; + } + + .weather-widget-summary { + max-width: 110px; + } +} diff --git a/frontend/src/components/WeatherWidget.tsx b/frontend/src/components/WeatherWidget.tsx new file mode 100644 index 0000000..783320f --- /dev/null +++ b/frontend/src/components/WeatherWidget.tsx @@ -0,0 +1,290 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api, WeatherDashboard } from "../api/client"; +import "./WeatherWidget.css"; + +const REFRESH_MS = 5 * 60 * 1000; + +function formatTime(iso?: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatHour(iso?: string): string { + if (!iso) return "—"; + return iso.slice(11, 16); +} + +function cacheLabel(cache: WeatherDashboard["cache"]): string { + if (!cache.has_data) return "нет данных"; + if (cache.cached && cache.expires_in_sec != null) { + return `кэш ${cache.age_sec ?? 0} с, обновление через ${cache.expires_in_sec} с`; + } + return "только что загружено"; +} + +interface WeatherWidgetProps { + compact?: boolean; +} + +export default function WeatherWidget({ compact = false }: WeatherWidgetProps) { + const [data, setData] = useState(null); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const rootRef = useRef(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const dash = await api.weatherDashboard(12); + setData(dash); + setError(dash.weather.ok ? null : dash.weather.error ?? "OpenMeteo недоступен"); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load().catch(() => undefined); + const timer = window.setInterval(() => { + load().catch(() => undefined); + }, REFRESH_MS); + return () => window.clearInterval(timer); + }, [load]); + + useEffect(() => { + if (!open) return; + load().catch(() => undefined); + }, [open, load]); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [open]); + + const cur = data?.weather.current; + const compactLabel = + cur?.temperature_c != null + ? `${Math.round(cur.temperature_c)}° · ${cur.conditions ?? "—"}` + : error + ? "погода ?" + : loading + ? "…" + : "погода"; + + return ( +
+ + + {open && ( +
+
+
+ OpenMeteo + + {data?.config.location ?? "—"} · {cacheLabel(data?.cache ?? { has_data: false, cached: false, fetched_at: null, age_sec: null, ttl_sec: 300, expires_in_sec: null })} + {data?.data_source === "fallback" && " · данные с api.open-meteo.com"} + +
+ +
+ + {error && !data?.weather.ok &&

{error}

} + + {data?.sync_hint && ( +

+ {data.sync_hint} + {data.recommended_sync && ( + <> +
+ SYNC_DOMAINS={data.recommended_sync.domains} +
+ SYNC_VARIABLES={data.recommended_sync.variables} + + )} +

+ )} + + {data?.field_coverage && + data.data_source !== "fallback" && + (data.field_coverage.current.length < data.available_fields.current.length || + data.field_coverage.hourly.length < data.available_fields.hourly.length) && ( +

+ OpenMeteo вернул не все поля. Пришло: current —{" "} + {data.field_coverage.current.join(", ") || "ничего"}; hourly —{" "} + {data.field_coverage.hourly.join(", ") || "ничего"}. Проверь sync на{" "} + {data.config.openmeteo_base_url}. +

+ )} + + {data?.local_field_coverage && + data.data_source === "fallback" && + (data.local_field_coverage.current.length < data.available_fields.current.length || + data.local_field_coverage.hourly.length < data.available_fields.hourly.length) && ( +

+ Локальный OpenMeteo ({data.config.openmeteo_base_url}) отдаёт только: current —{" "} + {data.local_field_coverage.current.join(", ") || "ничего"}; hourly —{" "} + {data.local_field_coverage.hourly.join(", ") || "ничего"}. Показаны данные fallback. +

+ )} + + {data?.weather.ok && cur && ( +
+

Сейчас

+
+
+
Время наблюдения
+
{formatTime(cur.time)}
+
+
+
Температура
+
+ {cur.temperature_c}°C (ощущается {cur.apparent_temperature_c ?? "—"}°C) +
+
+
+
Условия
+
+ {cur.conditions} (code {cur.weather_code ?? "—"}) +
+
+
+
Влажность
+
{cur.humidity_pct ?? "—"}%
+
+
+
Осадки сейчас
+
{cur.precipitation_mm ?? 0} мм
+
+
+
Ветер
+
{cur.wind_speed_kmh ?? "—"} км/ч
+
+
+
+ )} + + {data?.rain_summary && ( +
+

Осадки (12 ч)

+

{data.rain_summary}

+
+ )} + + {(data?.weather.hourly?.length ?? 0) > 0 && ( +
+

По часам

+
+ + + + + + + + + + + + {data!.weather.hourly!.map((row) => ( + + + + + + + + ))} + +
Время°CОсадкиВероятн.Условия
{formatHour(row.time)}{row.temperature_c ?? "—"}{row.precipitation_mm ?? 0} мм{row.precipitation_probability ?? "—"}%{row.conditions}
+
+
+ )} + + {data?.assistant_context && ( +
+

Контекст ассистента

+

{data.system_prompt}

+
{data.assistant_context}
+
+ )} + + {data && ( +
+

Источник

+
+
+
Координаты
+
+ {data.config.latitude}, {data.config.longitude} +
+
+
+
API
+
{data.config.openmeteo_base_url}/v1/forecast
+
+
+
TTL кэша
+
{data.config.cache_ttl_sec} с
+
+
+
Current fields
+
+ запрошено: {data.available_fields.current.join(", ")} +
+ получено: {data.field_coverage.current.join(", ") || "—"} +
+
+
+
Hourly fields
+
+ запрошено: {data.available_fields.hourly.join(", ")} +
+ получено: {data.field_coverage.hourly.join(", ") || "—"} +
+
+
+
    + {Object.entries(data.assistant_tools).map(([name, desc]) => ( +
  • + {name} — {desc} +
  • + ))} +
+
+ )} +
+ )} +
+ ); +}