daily
This commit is contained in:
@@ -64,6 +64,9 @@ WEATHER_LAT=59.9343
|
|||||||
WEATHER_LON=30.3351
|
WEATHER_LON=30.3351
|
||||||
WEATHER_LOCATION_NAME=Санкт-Петербург
|
WEATHER_LOCATION_NAME=Санкт-Петербург
|
||||||
WEATHER_CACHE_SEC=300
|
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 (comma-separated)
|
||||||
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
|
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import httpx
|
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.config import get_settings
|
||||||
|
from app.db.models import User
|
||||||
from app.homelab.comfyui import _use_anima
|
from app.homelab.comfyui import _use_anima
|
||||||
|
from app.homelab.openmeteo import build_weather_dashboard
|
||||||
|
|
||||||
router = APIRouter(prefix="/homelab", tags=["homelab"])
|
router = APIRouter(prefix="/homelab", tags=["homelab"])
|
||||||
|
|
||||||
@@ -40,3 +43,12 @@ def homelab_status() -> dict:
|
|||||||
"rp_chat_enabled": settings.rp_chat_enabled,
|
"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)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ TOOLS_INSTRUCTIONS = """
|
|||||||
- В текстовых ответах пользователю не используй эмодзи.
|
- В текстовых ответах пользователю не используй эмодзи.
|
||||||
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
|
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
|
||||||
- Утренний брифинг (погода + новости) → get_morning_briefing.
|
- Утренний брифинг (погода + новости) → 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.
|
- Покупки: 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. Не выдумывай списки.
|
- «Добавь в список покупок» → add_shopping_items (list_name + товары). «Что купить» → list_shopping_lists. Не выдумывай списки.
|
||||||
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.
|
- Напоминания: list_reminders, create_reminder, update_reminder, delete_reminder, complete_reminder.
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class Settings(BaseSettings):
|
|||||||
weather_lon: float = 30.3351
|
weather_lon: float = 30.3351
|
||||||
weather_location_name: str = "Санкт-Петербург"
|
weather_location_name: str = "Санкт-Петербург"
|
||||||
weather_cache_sec: int = 300
|
weather_cache_sec: int = 300
|
||||||
|
openmeteo_fallback_url: str = "https://api.open-meteo.com"
|
||||||
|
openmeteo_fallback_on_partial: bool = True
|
||||||
|
|
||||||
news_rss_urls: str = (
|
news_rss_urls: str = (
|
||||||
"https://habr.com/ru/rss/all/all/,"
|
"https://habr.com/ru/rss/all/all/,"
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"""Сборка Anima-промптов без LLM (теги, без POV/hybrid)."""
|
"""Сборка Anima-промптов: appearance из карточки + action/outfit из контекста."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
ANIMA_QUALITY = "masterpiece, best quality, score_7, anime"
|
ANIMA_QUALITY = "masterpiece, best quality, score_7, anime"
|
||||||
ANIMA_NEGATIVE = "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
|
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({
|
_JUNK_STANDALONE_TAGS = frozenset({
|
||||||
"white", "black", "skin", "ear", "ears", "girl", "boy", "fox", "wolf", "cat",
|
"white", "black", "skin", "ear", "ears", "girl", "boy", "fox", "wolf", "cat",
|
||||||
"short", "tall", "golden", "silver", "red", "blue", "green", "purple",
|
"short", "tall", "golden", "silver", "red", "blue", "green", "purple",
|
||||||
@@ -33,6 +37,8 @@ def _sanitize_tags(tag_str: str) -> str:
|
|||||||
key = t.lower().replace(" ", "_")
|
key = t.lower().replace(" ", "_")
|
||||||
if key in seen or len(key) <= 2:
|
if key in seen or len(key) <= 2:
|
||||||
continue
|
continue
|
||||||
|
if key in _INVALID_TAGS:
|
||||||
|
continue
|
||||||
if "_" not in key and key in _JUNK_STANDALONE_TAGS:
|
if "_" not in key and key in _JUNK_STANDALONE_TAGS:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
@@ -48,20 +54,26 @@ def _append_lora(parts: list[str], lora_name: str, lora_weight: float) -> None:
|
|||||||
parts.append(f"<lora:{lora}:{weight}>")
|
parts.append(f"<lora:{lora}:{weight}>")
|
||||||
|
|
||||||
|
|
||||||
def build_draw_self_prompt(
|
def build_character_image_prompt(
|
||||||
appearance_tags: str,
|
appearance_tags: str,
|
||||||
*,
|
*,
|
||||||
|
action_tags: str = "",
|
||||||
|
outfit_tags: str = "",
|
||||||
|
environment_tags: str = "",
|
||||||
lora_name: str = "",
|
lora_name: str = "",
|
||||||
lora_weight: float = 0.8,
|
lora_weight: float = 0.8,
|
||||||
) -> AnimaPromptBundle:
|
) -> AnimaPromptBundle:
|
||||||
"""Портрет «нарисуй себя» — только booru-теги, без POV и prose."""
|
"""Appearance (карточка) + action/outfit/env (контекст), только теги."""
|
||||||
appearance = _sanitize_tags(appearance_tags)
|
appearance = _sanitize_tags(appearance_tags)
|
||||||
action = "looking_at_viewer, smile, upper_body, portrait"
|
outfit = _sanitize_tags(outfit_tags)
|
||||||
environment = "simple_background, soft_lighting"
|
action = _sanitize_tags(action_tags) or "looking_at_viewer, smile"
|
||||||
|
environment = _sanitize_tags(environment_tags) or "simple_background, soft_lighting"
|
||||||
|
|
||||||
parts = [ANIMA_QUALITY]
|
parts = [ANIMA_QUALITY]
|
||||||
if appearance:
|
if appearance:
|
||||||
parts.append(appearance)
|
parts.append(appearance)
|
||||||
|
if outfit:
|
||||||
|
parts.append(outfit)
|
||||||
parts.append(action)
|
parts.append(action)
|
||||||
parts.append(environment)
|
parts.append(environment)
|
||||||
_append_lora(parts, lora_name, lora_weight)
|
_append_lora(parts, lora_name, lora_weight)
|
||||||
@@ -70,6 +82,25 @@ def build_draw_self_prompt(
|
|||||||
return AnimaPromptBundle(positive=positive, negative=ANIMA_NEGATIVE)
|
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(
|
def build_scene_tags_prompt(
|
||||||
scene_tags: str,
|
scene_tags: str,
|
||||||
appearance_tags: str,
|
appearance_tags: str,
|
||||||
@@ -77,24 +108,13 @@ def build_scene_tags_prompt(
|
|||||||
lora_name: str = "",
|
lora_name: str = "",
|
||||||
lora_weight: float = 0.8,
|
lora_weight: float = 0.8,
|
||||||
) -> AnimaPromptBundle:
|
) -> AnimaPromptBundle:
|
||||||
"""Прямая сцена из booru-тегов (без LLM)."""
|
"""Готовые booru-теги сцены + appearance."""
|
||||||
appearance = _sanitize_tags(appearance_tags)
|
|
||||||
scene = _sanitize_tags(scene_tags)
|
scene = _sanitize_tags(scene_tags)
|
||||||
parts = [ANIMA_QUALITY]
|
return build_character_image_prompt(
|
||||||
if appearance:
|
appearance_tags,
|
||||||
parts.append(appearance)
|
action_tags=scene,
|
||||||
if scene:
|
outfit_tags="",
|
||||||
parts.append(scene)
|
environment_tags="simple_background, soft_lighting",
|
||||||
_append_lora(parts, lora_name, lora_weight)
|
lora_name=lora_name,
|
||||||
positive = ", ".join(p.strip() for p in parts if p.strip())
|
lora_weight=lora_weight,
|
||||||
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))
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.homelab.anima_prompt import AnimaPromptBundle, build_draw_self_prompt, build_scene_tags_prompt, looks_like_booru_tags
|
|
||||||
|
|
||||||
|
from app.character.service import CharacterService
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
@@ -33,6 +34,13 @@ def _session_messages(db: Session, session_id: int | None, limit: int = 8) -> li
|
|||||||
|
|
||||||
if not session_id:
|
if not session_id:
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from app.db.models import Message
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -44,8 +52,9 @@ async def _generate_from_bundle(
|
|||||||
|
|
||||||
Message.session_id == session_id,
|
Message.session_id == session_id,
|
||||||
|
|
||||||
|
Message.role.in_(("user", "assistant")),
|
||||||
|
|
||||||
)
|
)
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
.order_by(Message.created_at.desc())
|
.order_by(Message.created_at.desc())
|
||||||
|
|
||||||
@@ -62,7 +71,8 @@ async def _generate_from_bundle(
|
|||||||
|
|
||||||
|
|
||||||
def _last_user_message(messages: list[dict[str, str]]) -> str:
|
def _last_user_message(messages: list[dict[str, str]]) -> str:
|
||||||
"prompt_mode": "direct",
|
|
||||||
|
for msg in reversed(messages):
|
||||||
|
|
||||||
if msg.get("role") == "user" and (msg.get("content") or "").strip():
|
if msg.get("role") == "user" and (msg.get("content") or "").strip():
|
||||||
|
|
||||||
@@ -71,11 +81,32 @@ async def _generate_from_bundle(
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
result["prompt_mode"] = "direct"
|
|
||||||
|
|
||||||
|
|
||||||
def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str:
|
def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str:
|
||||||
|
|
||||||
if not lora_name or f"<lora:{lora_name}" in positive:
|
if not lora_name or f"<lora:{lora_name}" in positive:
|
||||||
|
|
||||||
|
return positive
|
||||||
|
|
||||||
|
return f"{positive} <lora:{lora_name}:{lora_weight}>"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_from_bundle(
|
||||||
|
|
||||||
|
bundle: AnimaPromptBundle,
|
||||||
|
|
||||||
|
*,
|
||||||
|
|
||||||
|
backend: str,
|
||||||
|
|
||||||
|
persona_id: str = "",
|
||||||
|
|
||||||
|
prompt_mode: str = "direct",
|
||||||
|
|
||||||
tag_source: str = "",
|
tag_source: str = "",
|
||||||
|
|
||||||
@@ -98,6 +129,7 @@ async def generate_image(
|
|||||||
return saved
|
return saved
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|
||||||
"url": saved["url"],
|
"url": saved["url"],
|
||||||
@@ -105,27 +137,55 @@ async def generate_image(
|
|||||||
"filename": saved["filename"],
|
"filename": saved["filename"],
|
||||||
|
|
||||||
"prompt": bundle.positive,
|
"prompt": bundle.positive,
|
||||||
bundle = build_draw_self_prompt(
|
|
||||||
|
"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"):
|
if result.get("ok"):
|
||||||
return await _generate_from_bundle(bundle, backend=backend, persona_id=persona_id)
|
|
||||||
|
result["backend"] = "comfyui_local"
|
||||||
|
|
||||||
|
result["prompt_mode"] = prompt_mode
|
||||||
|
|
||||||
|
result["negative_prompt"] = bundle.negative
|
||||||
|
|
||||||
result["tag_source"] = tag_source
|
result["tag_source"] = tag_source
|
||||||
scene = scene_description.strip()
|
|
||||||
if looks_like_booru_tags(scene):
|
return result
|
||||||
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,
|
async def _build_contextual_bundle(
|
||||||
lora_name=lora_name,
|
|
||||||
lora_weight=lora_weight,
|
appearance: str,
|
||||||
)
|
|
||||||
return await _generate_from_bundle(bundle, backend=backend, persona_id=persona_id)
|
*,
|
||||||
|
|
||||||
|
request: str,
|
||||||
|
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
|
||||||
messages = _session_messages(db, session_id)
|
|
||||||
lora_name: str,
|
lora_name: str,
|
||||||
|
|
||||||
lora_weight: float,
|
lora_weight: float,
|
||||||
@@ -188,3 +248,4 @@ async def _generate_via_rp_chat(
|
|||||||
|
|
||||||
return {"ok": False, "error": "Нужен draw_self=true или scene_description"}
|
return {"ok": False, "error": "Нужен draw_self=true или scene_description"}
|
||||||
|
|
||||||
|
|
||||||
@@ -29,42 +29,172 @@ WEATHER_CODES: dict[int, str] = {
|
|||||||
99: "гроза с градом",
|
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:
|
class OpenMeteoClient:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
self.base_url = settings.openmeteo_base_url.rstrip("/")
|
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.lat = settings.weather_lat
|
||||||
self.lon = settings.weather_lon
|
self.lon = settings.weather_lon
|
||||||
self.location_name = settings.weather_location_name
|
self.location_name = settings.weather_location_name
|
||||||
self.cache_ttl = settings.weather_cache_sec
|
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]:
|
def _fetch_raw(self) -> dict[str, Any]:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if _cache["data"] and now < _cache["expires_at"]:
|
if _cache["data"] and now < _cache["expires_at"]:
|
||||||
return _cache["data"]
|
return _cache["data"]
|
||||||
|
|
||||||
params = {
|
local_raw = self._fetch_from_url(self.base_url)
|
||||||
"latitude": self.lat,
|
local_coverage = _field_coverage(local_raw)
|
||||||
"longitude": self.lon,
|
source = "local"
|
||||||
"current": (
|
raw = local_raw
|
||||||
"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()
|
|
||||||
|
|
||||||
_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
|
_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]:
|
def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
@@ -75,21 +205,32 @@ class OpenMeteoClient:
|
|||||||
current = raw.get("current") or {}
|
current = raw.get("current") or {}
|
||||||
hourly = raw.get("hourly") or {}
|
hourly = raw.get("hourly") or {}
|
||||||
times = hourly.get("time") 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 = []
|
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({
|
hourly_slice.append({
|
||||||
"time": times[i],
|
"time": times[i],
|
||||||
"temperature_c": hourly.get("temperature_2m", [None])[i],
|
"temperature_c": temp_series[i] if i < len(temp_series) else None,
|
||||||
"precipitation_mm": hourly.get("precipitation", [None])[i],
|
"precipitation_mm": precip_series[i] if i < len(precip_series) else None,
|
||||||
"precipitation_probability": hourly.get("precipitation_probability", [None])[i],
|
"precipitation_probability": prob_series[i] if i < len(prob_series) else None,
|
||||||
"weather_code": hourly.get("weather_code", [None])[i],
|
"weather_code": code,
|
||||||
|
"conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно",
|
||||||
})
|
})
|
||||||
|
|
||||||
code = current.get("weather_code")
|
code = current.get("weather_code")
|
||||||
|
coverage = _field_coverage(raw)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"location": self.location_name,
|
"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": {
|
"current": {
|
||||||
"time": current.get("time"),
|
"time": current.get("time"),
|
||||||
"temperature_c": current.get("temperature_2m"),
|
"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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
cur = snapshot.get("current") or {}
|
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(
|
lines.append(
|
||||||
f"{snapshot.get('location')}: {cur.get('temperature_c')}°C "
|
f"{snapshot.get('location')}: {_fmt_num(cur.get('temperature_c'), suffix='°C')}"
|
||||||
f"(ощущается {cur.get('apparent_temperature_c')}°C), "
|
f"{apparent_part}, {cur.get('conditions') or 'неизвестно'}{wind_part}."
|
||||||
f"{cur.get('conditions')}, ветер {cur.get('wind_speed_kmh')} км/ч."
|
|
||||||
)
|
)
|
||||||
hourly = snapshot.get("hourly") or []
|
hourly = snapshot.get("hourly") or []
|
||||||
rainy_hours = []
|
rainy_hours = []
|
||||||
@@ -151,3 +295,42 @@ def format_weather_snapshot(data: dict[str, Any] | None = None) -> str:
|
|||||||
lines.append("Существенных осадков в ближайшие часы не ожидается.")
|
lines.append("Существенных осадков в ближайшие часы не ожидается.")
|
||||||
lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.")
|
lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.")
|
||||||
return "\n".join(lines)
|
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 ч почасово).",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -576,21 +576,24 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "generate_image",
|
"name": "generate_image",
|
||||||
"description": (
|
"description": (
|
||||||
"Аниме-картинка (Anima через RP-чат). "
|
"Аниме-картинка (Anima). draw_self=true — персонаж из карточки; "
|
||||||
"«Нарисуй себя» / портрет персонажа → draw_self=true. "
|
"scene_description — поза/кадр/одежда (booru-теги на англ. или короткий запрос: "
|
||||||
"Другая сцена → scene_description на английском (booru-теги). "
|
"full body, sitting, apron). Можно оба параметра: draw_self + scene_description. "
|
||||||
"Внешность берётся из карточки персонажа. Только по запросу или когда уместно."
|
"Внешность только из appearance_tags карточки."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"draw_self": {
|
"draw_self": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Нарисовать персонажа из карточки в контексте текущего чата",
|
"description": "Нарисовать персонажа из карточки",
|
||||||
},
|
},
|
||||||
"scene_description": {
|
"scene_description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Описание сцены на английском (booru-теги), если не draw_self",
|
"description": (
|
||||||
|
"Поза, кадр, одежда, обстановка — booru-теги или запрос "
|
||||||
|
"(full_body, standing, apron, blush). С draw_self=true — уточняет сцену."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": [],
|
"required": [],
|
||||||
|
|||||||
@@ -30,6 +30,6 @@
|
|||||||
- add_shopping_items, list_shopping_lists, check_shopping_item
|
- add_shopping_items, list_shopping_lists, check_shopping_item
|
||||||
|
|
||||||
Картинки:
|
Картинки:
|
||||||
- «Нарисуй себя» → generate_image с draw_self=true (портрет по appearance_tags, LLM sd-prompt не нужен)
|
- «Нарисуй себя» → generate_image draw_self=true; «в полный рост» → scene_description="full_body, standing"
|
||||||
- Другая сцена → generate_image с scene_description на английском (booru-теги; если теги — тоже без LLM)
|
- Другая сцена → scene_description (booru-теги или короткий запрос); draw_self=true если персонаж из карточки
|
||||||
- Внешность персонажа задаётся в настройках карточки, не выдумывай теги
|
- Внешность персонажа задаётся в настройках карточки, не выдумывай теги
|
||||||
|
|||||||
@@ -1,18 +1,34 @@
|
|||||||
from app.homelab.anima_prompt import (
|
from app.homelab.anima_prompt import (
|
||||||
|
build_character_image_prompt,
|
||||||
build_draw_self_prompt,
|
build_draw_self_prompt,
|
||||||
build_scene_tags_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():
|
def test_build_character_image_prompt_full_body():
|
||||||
bundle = build_draw_self_prompt("silver_hair, wolf_ears, blue_eyes")
|
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 "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():
|
def test_build_draw_self_prompt_lora():
|
||||||
@@ -23,3 +39,14 @@ def test_build_draw_self_prompt_lora():
|
|||||||
def test_looks_like_booru_tags():
|
def test_looks_like_booru_tags():
|
||||||
assert looks_like_booru_tags("1girl, smile, indoors")
|
assert looks_like_booru_tags("1girl, smile, indoors")
|
||||||
assert not looks_like_booru_tags("draw a picture of a cat on the moon")
|
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"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NavLink, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import PomodoroWidget from "./components/PomodoroWidget";
|
import PomodoroWidget from "./components/PomodoroWidget";
|
||||||
|
import WeatherWidget from "./components/WeatherWidget";
|
||||||
|
|
||||||
import RequireAuth from "./components/RequireAuth";
|
import RequireAuth from "./components/RequireAuth";
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ function AppShell() {
|
|||||||
|
|
||||||
<NavLink to="/reminders">Календарь</NavLink>
|
<NavLink to="/reminders">Календарь</NavLink>
|
||||||
|
|
||||||
|
<WeatherWidget compact />
|
||||||
<PomodoroWidget compact />
|
<PomodoroWidget compact />
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
|
|||||||
@@ -147,6 +147,73 @@ export interface PomodoroStatus {
|
|||||||
cycle: PomodoroCycle;
|
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<string, string>;
|
||||||
|
system_prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CharacterCardData {
|
export interface CharacterCardData {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -567,6 +634,9 @@ export const api = {
|
|||||||
{ method: "POST" }
|
{ method: "POST" }
|
||||||
),
|
),
|
||||||
|
|
||||||
|
weatherDashboard: (hoursAhead = 12) =>
|
||||||
|
request<WeatherDashboard>(`/api/v1/homelab/weather?hours_ahead=${hoursAhead}`),
|
||||||
|
|
||||||
getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
|
getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
|
||||||
|
|
||||||
saveCharacter: (card: CharacterCardV2) =>
|
saveCharacter: (card: CharacterCardV2) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WeatherDashboard | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={`weather-widget ${compact ? "compact" : ""} ${open ? "open" : ""}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="weather-widget-trigger"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
title="OpenMeteo — что видит ассистент"
|
||||||
|
>
|
||||||
|
<span className="weather-widget-icon" aria-hidden>
|
||||||
|
🌤
|
||||||
|
</span>
|
||||||
|
<span className="weather-widget-summary">{compactLabel}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="weather-widget-panel">
|
||||||
|
<div className="weather-widget-panel-head">
|
||||||
|
<div>
|
||||||
|
<strong>OpenMeteo</strong>
|
||||||
|
<span className="weather-widget-sub">
|
||||||
|
{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"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="weather-widget-refresh" onClick={() => load()} disabled={loading}>
|
||||||
|
{loading ? "…" : "↻"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !data?.weather.ok && <p className="weather-widget-error">{error}</p>}
|
||||||
|
|
||||||
|
{data?.sync_hint && (
|
||||||
|
<p className="weather-widget-warn">
|
||||||
|
{data.sync_hint}
|
||||||
|
{data.recommended_sync && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<code>SYNC_DOMAINS={data.recommended_sync.domains}</code>
|
||||||
|
<br />
|
||||||
|
<code>SYNC_VARIABLES={data.recommended_sync.variables}</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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) && (
|
||||||
|
<p className="weather-widget-warn">
|
||||||
|
OpenMeteo вернул не все поля. Пришло: current —{" "}
|
||||||
|
{data.field_coverage.current.join(", ") || "ничего"}; hourly —{" "}
|
||||||
|
{data.field_coverage.hourly.join(", ") || "ничего"}. Проверь sync на{" "}
|
||||||
|
{data.config.openmeteo_base_url}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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) && (
|
||||||
|
<p className="weather-widget-warn">
|
||||||
|
Локальный OpenMeteo ({data.config.openmeteo_base_url}) отдаёт только: current —{" "}
|
||||||
|
{data.local_field_coverage.current.join(", ") || "ничего"}; hourly —{" "}
|
||||||
|
{data.local_field_coverage.hourly.join(", ") || "ничего"}. Показаны данные fallback.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.weather.ok && cur && (
|
||||||
|
<section className="weather-widget-section">
|
||||||
|
<h4>Сейчас</h4>
|
||||||
|
<dl className="weather-widget-dl">
|
||||||
|
<div>
|
||||||
|
<dt>Время наблюдения</dt>
|
||||||
|
<dd>{formatTime(cur.time)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Температура</dt>
|
||||||
|
<dd>
|
||||||
|
{cur.temperature_c}°C (ощущается {cur.apparent_temperature_c ?? "—"}°C)
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Условия</dt>
|
||||||
|
<dd>
|
||||||
|
{cur.conditions} (code {cur.weather_code ?? "—"})
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Влажность</dt>
|
||||||
|
<dd>{cur.humidity_pct ?? "—"}%</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Осадки сейчас</dt>
|
||||||
|
<dd>{cur.precipitation_mm ?? 0} мм</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Ветер</dt>
|
||||||
|
<dd>{cur.wind_speed_kmh ?? "—"} км/ч</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.rain_summary && (
|
||||||
|
<section className="weather-widget-section">
|
||||||
|
<h4>Осадки (12 ч)</h4>
|
||||||
|
<p className="weather-widget-note">{data.rain_summary}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(data?.weather.hourly?.length ?? 0) > 0 && (
|
||||||
|
<section className="weather-widget-section">
|
||||||
|
<h4>По часам</h4>
|
||||||
|
<div className="weather-widget-table-wrap">
|
||||||
|
<table className="weather-widget-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>°C</th>
|
||||||
|
<th>Осадки</th>
|
||||||
|
<th>Вероятн.</th>
|
||||||
|
<th>Условия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data!.weather.hourly!.map((row) => (
|
||||||
|
<tr key={row.time}>
|
||||||
|
<td>{formatHour(row.time)}</td>
|
||||||
|
<td>{row.temperature_c ?? "—"}</td>
|
||||||
|
<td>{row.precipitation_mm ?? 0} мм</td>
|
||||||
|
<td>{row.precipitation_probability ?? "—"}%</td>
|
||||||
|
<td>{row.conditions}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.assistant_context && (
|
||||||
|
<section className="weather-widget-section">
|
||||||
|
<h4>Контекст ассистента</h4>
|
||||||
|
<p className="weather-widget-note">{data.system_prompt}</p>
|
||||||
|
<pre className="weather-widget-context">{data.assistant_context}</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<section className="weather-widget-section">
|
||||||
|
<h4>Источник</h4>
|
||||||
|
<dl className="weather-widget-dl compact-dl">
|
||||||
|
<div>
|
||||||
|
<dt>Координаты</dt>
|
||||||
|
<dd>
|
||||||
|
{data.config.latitude}, {data.config.longitude}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>API</dt>
|
||||||
|
<dd>{data.config.openmeteo_base_url}/v1/forecast</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>TTL кэша</dt>
|
||||||
|
<dd>{data.config.cache_ttl_sec} с</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Current fields</dt>
|
||||||
|
<dd>
|
||||||
|
запрошено: {data.available_fields.current.join(", ")}
|
||||||
|
<br />
|
||||||
|
получено: {data.field_coverage.current.join(", ") || "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Hourly fields</dt>
|
||||||
|
<dd>
|
||||||
|
запрошено: {data.available_fields.hourly.join(", ")}
|
||||||
|
<br />
|
||||||
|
получено: {data.field_coverage.hourly.join(", ") || "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<ul className="weather-widget-tools">
|
||||||
|
{Object.entries(data.assistant_tools).map(([name, desc]) => (
|
||||||
|
<li key={name}>
|
||||||
|
<code>{name}</code> — {desc}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user