added api
This commit is contained in:
@@ -42,6 +42,52 @@ GITEA_WEBHOOK_SECRET=generate_a_random_secret
|
|||||||
|
|
||||||
REPOS_DIR=/data/repos
|
REPOS_DIR=/data/repos
|
||||||
|
|
||||||
|
# Homelab — GPU PC 192.168.1.109
|
||||||
|
OPENMETEO_BASE_URL=http://192.168.1.109:8085
|
||||||
|
WEATHER_LAT=59.9343
|
||||||
|
WEATHER_LON=30.3351
|
||||||
|
WEATHER_LOCATION_NAME=Санкт-Петербург
|
||||||
|
WEATHER_CACHE_SEC=300
|
||||||
|
|
||||||
|
# News RSS (comma-separated)
|
||||||
|
NEWS_RSS_URLS=https://habr.com/ru/rss/all/all/,https://www.reddit.com/r/programming/.rss
|
||||||
|
NEWS_CACHE_SEC=1800
|
||||||
|
NEWS_MAX_ITEMS=7
|
||||||
|
|
||||||
|
# Morning digest (Europe/Moscow or user profile timezone)
|
||||||
|
MORNING_DIGEST_ENABLED=true
|
||||||
|
MORNING_DIGEST_HOUR=8
|
||||||
|
MORNING_DIGEST_MINUTE=0
|
||||||
|
|
||||||
|
# ComfyUI on GPU PC (Anima split-model — как в aiChatBot)
|
||||||
|
COMFYUI_BASE_URL=http://192.168.1.109:8188
|
||||||
|
COMFYUI_ENABLED=true
|
||||||
|
# Anima: UNET+CLIP+VAE, CHECKPOINT пустой. Для SD1.5/Pony — задай CHECKPOINT, очисти UNET.
|
||||||
|
COMFYUI_CHECKPOINT=
|
||||||
|
COMFYUI_UNET=anima-preview3-base.safetensors
|
||||||
|
COMFYUI_CLIP=qwen_3_06b_base.safetensors
|
||||||
|
COMFYUI_VAE=qwen_image_vae.safetensors
|
||||||
|
COMFYUI_STYLE_LORA=anima-preview-3-masterpieces-v5.safetensors
|
||||||
|
COMFYUI_STYLE_LORA_WEIGHT=0.7
|
||||||
|
COMFYUI_STEPS=30
|
||||||
|
COMFYUI_CFG=4
|
||||||
|
COMFYUI_SAMPLER=er_sde
|
||||||
|
COMFYUI_SCHEDULER=simple
|
||||||
|
COMFYUI_WIDTH=1024
|
||||||
|
COMFYUI_HEIGHT=720
|
||||||
|
COMFYUI_NEGATIVE_PROMPT=worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia
|
||||||
|
COMFYUI_ROFL_ENABLED=true
|
||||||
|
COMFYUI_ROFL_MAX_PER_DAY=1
|
||||||
|
COMFYUI_ROFL_PROBABILITY=0.15
|
||||||
|
COMFYUI_ROFL_MIN_INTERVAL_HOURS=12
|
||||||
|
GENERATED_MEDIA_DIR=./data/generated
|
||||||
|
|
||||||
|
# Netdata on server
|
||||||
|
NETDATA_BASE_URL=http://host.docker.internal:19999
|
||||||
|
NETDATA_PUBLIC_URL=
|
||||||
|
NETDATA_ALERTS_ENABLED=true
|
||||||
|
NETDATA_POLL_INTERVAL_SEC=120
|
||||||
|
|
||||||
# Vector DB (phase 3)
|
# Vector DB (phase 3)
|
||||||
QDRANT_PORT=6333
|
QDRANT_PORT=6333
|
||||||
QDRANT_GRPC_PORT=6334
|
QDRANT_GRPC_PORT=6334
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ data/ SQLite БД (создаётся автоматически)
|
|||||||
| **Факты** | устойчивые знания с категорией и важностью |
|
| **Факты** | устойчивые знания с категорией и важностью |
|
||||||
| **Сводка чата** | краткое содержание длинной сессии |
|
| **Сводка чата** | краткое содержание длинной сессии |
|
||||||
|
|
||||||
В system prompt на каждый ответ: персонаж → **память** → помидоро → проекты.
|
В system prompt на каждый ответ: персонаж → **время** → память → фитнес → **погода** → помидоро → проекты.
|
||||||
История чата обрезается до 40 последних сообщений; раннее — в `session_summaries`.
|
История чата обрезается до 40 последних сообщений; раннее — в `session_summaries`.
|
||||||
|
|
||||||
**Автоизвлечение:** после каждого ответа LLM анализирует ход диалога и сохраняет
|
**Автоизвлечение:** после каждого ответа LLM анализирует ход диалога и сохраняет
|
||||||
@@ -208,12 +208,56 @@ lookup wger + Open Food Facts, напоминания в чат (`💪`), вкл
|
|||||||
|
|
||||||
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
|
Чат: «обед: гречка 200г, курица 150г», «выпил 300 мл воды», «жим 80×5×3».
|
||||||
|
|
||||||
|
## Homelab API (фаза 4)
|
||||||
|
|
||||||
|
Интеграции с домашней инфраструктурой:
|
||||||
|
|
||||||
|
| Сервис | URL по умолчанию | Назначение |
|
||||||
|
|--------|------------------|------------|
|
||||||
|
| Open-Meteo | `http://192.168.1.109:8085` | Погода СПб в контексте и tool `get_weather` |
|
||||||
|
| ComfyUI | `http://192.168.1.109:8188` | `generate_image`, редкий «рофл» в чат |
|
||||||
|
| Netdata | `http://host.docker.internal:19999` | Алерты warning/critical → notice в чат |
|
||||||
|
|
||||||
|
**Утренний дайджест** (`MORNING_DIGEST_HOUR=8`): погода + RSS (Habr, r/programming по умолчанию).
|
||||||
|
По запросу: «что на улице», «будет ли дождь» → `get_weather`; полный брифинг → `get_morning_briefing`.
|
||||||
|
|
||||||
|
Переменные — в `.env.example` (секция Homelab).
|
||||||
|
|
||||||
|
### Проверка доступности
|
||||||
|
|
||||||
|
В образе backend нет `curl`/`wget`. Удобнее всего — API-диагностика (из контейнера или с хоста):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:${BACKEND_PORT:-8202}/api/v1/homelab/status | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Или изнутри backend через Python:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backend python -c "
|
||||||
|
import httpx
|
||||||
|
for url in [
|
||||||
|
'http://192.168.1.109:8085/v1/forecast?latitude=59.93&longitude=30.33¤t=temperature_2m',
|
||||||
|
'http://192.168.1.109:8188/system_stats',
|
||||||
|
'http://host.docker.internal:19999/api/v1/info',
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
r = httpx.get(url, timeout=10)
|
||||||
|
print(url, '->', r.status_code, r.text[:120])
|
||||||
|
except Exception as e:
|
||||||
|
print(url, '-> ERROR', e)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
По умолчанию **Anima** (как в aiChatBot): `COMFYUI_UNET` + `COMFYUI_CLIP` + `COMFYUI_VAE` + style LoRA.
|
||||||
|
`COMFYUI_CHECKPOINT` оставь пустым. Для SD1.5/Pony — укажи checkpoint и очисти `COMFYUI_UNET`.
|
||||||
|
|
||||||
## Следующие фазы
|
## Следующие фазы
|
||||||
|
|
||||||
- Фаза 4: инструменты с обращением к внешним API
|
- RAG по файлам (Qdrant)
|
||||||
- Фаза 5: RAG по файлам
|
- Telegram-бот
|
||||||
- Проактивные чаты по расписанию
|
- Taiga/fitness в утреннем дайджесте
|
||||||
- Telegram, графики веса, LLM-мотивация в напоминаниях
|
- Графики веса, LLM-мотивация в напоминаниях
|
||||||
|
|
||||||
## Модель
|
## Модель
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import character, chat, fitness, health, memory, pomodoro, projects, webhooks
|
from app.api.routes import character, chat, fitness, health, homelab, media, memory, pomodoro, projects, webhooks
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
|
api_router.include_router(homelab.router, tags=["homelab"])
|
||||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||||
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
api_router.include_router(pomodoro.router, prefix="/pomodoro", tags=["pomodoro"])
|
||||||
api_router.include_router(character.router, tags=["character"])
|
api_router.include_router(character.router, tags=["character"])
|
||||||
@@ -11,3 +12,4 @@ api_router.include_router(projects.router, tags=["projects"])
|
|||||||
api_router.include_router(memory.router, tags=["memory"])
|
api_router.include_router(memory.router, tags=["memory"])
|
||||||
api_router.include_router(fitness.router, tags=["fitness"])
|
api_router.include_router(fitness.router, tags=["fitness"])
|
||||||
api_router.include_router(webhooks.router, tags=["webhooks"])
|
api_router.include_router(webhooks.router, tags=["webhooks"])
|
||||||
|
api_router.include_router(media.router, tags=["media"])
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.homelab.comfyui import _use_anima
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/homelab", tags=["homelab"])
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(url: str, *, timeout: float = 10.0) -> dict:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
body = response.text[:500]
|
||||||
|
return {
|
||||||
|
"ok": response.status_code < 400,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"preview": body,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def homelab_status() -> dict:
|
||||||
|
settings = get_settings()
|
||||||
|
comfy_backend = "anima" if _use_anima(settings) else "checkpoint"
|
||||||
|
return {
|
||||||
|
"openmeteo": _probe(f"{settings.openmeteo_base_url.rstrip('/')}/v1/forecast?latitude=0&longitude=0¤t=temperature_2m"),
|
||||||
|
"comfyui": _probe(f"{settings.comfyui_base_url.rstrip('/')}/system_stats"),
|
||||||
|
"netdata": _probe(f"{settings.netdata_base_url.rstrip('/')}/api/v1/info"),
|
||||||
|
"config": {
|
||||||
|
"openmeteo_base_url": settings.openmeteo_base_url,
|
||||||
|
"comfyui_base_url": settings.comfyui_base_url,
|
||||||
|
"comfyui_backend": comfy_backend,
|
||||||
|
"comfyui_unet": settings.comfyui_unet,
|
||||||
|
"netdata_base_url": settings.netdata_base_url,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/media", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/generated/{filename}")
|
||||||
|
def get_generated_image(filename: str) -> FileResponse:
|
||||||
|
if ".." in filename or "/" in filename or "\\" in filename:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
path = Path(settings.generated_media_dir) / filename
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
return FileResponse(path, media_type="image/png")
|
||||||
@@ -19,6 +19,10 @@ TOOLS_INSTRUCTIONS = """
|
|||||||
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
|
- Сценарий персонажа (сын, семья) — тон общения, НЕ факты о пользователе.
|
||||||
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
|
- Снимок проектов/задач и памяти есть в контексте, но для записи/поиска вызывай tools.
|
||||||
- Никогда не пиши «ожидаю ответа от системы».
|
- Никогда не пиши «ожидаю ответа от системы».
|
||||||
|
- В текстовых ответах пользователю не используй эмодзи.
|
||||||
|
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
|
||||||
|
- Утренний брифинг (погода + новости) → get_morning_briefing.
|
||||||
|
- Картинки: generate_image (ComfyUI Anima) — промпт на английском, booru-теги + короткое описание; не злоупотребляй.
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
DEFAULT_CARD: dict[str, Any] = {
|
DEFAULT_CARD: dict[str, Any] = {
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ TOOLS_SKIP_CHAT_NOTICE = frozenset({
|
|||||||
"lookup_food",
|
"lookup_food",
|
||||||
"lookup_exercise",
|
"lookup_exercise",
|
||||||
"calc_fitness_targets",
|
"calc_fitness_targets",
|
||||||
|
"get_weather",
|
||||||
|
"get_morning_briefing",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -190,6 +192,10 @@ def format_tool_notice(tool_name: str, raw_result: str) -> str | None:
|
|||||||
state = "вкл" if r.get("enabled") else "выкл"
|
state = "вкл" if r.get("enabled") else "выкл"
|
||||||
return f"💪 **Напоминание {r.get('kind')}** · {state}"
|
return f"💪 **Напоминание {r.get('kind')}** · {state}"
|
||||||
|
|
||||||
|
if tool_name == "generate_image" and data.get("ok"):
|
||||||
|
url = data.get("url", "")
|
||||||
|
return f"🎨 **Картинка готова**\n\n"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from app.chat.notices import (
|
|||||||
format_tool_notice,
|
format_tool_notice,
|
||||||
)
|
)
|
||||||
from app.fitness.context import format_fitness_context, get_fitness_snapshot
|
from app.fitness.context import format_fitness_context, get_fitness_snapshot
|
||||||
|
from app.homelab.context import format_datetime_context
|
||||||
|
from app.homelab.openmeteo import format_weather_snapshot
|
||||||
from app.memory.context import (
|
from app.memory.context import (
|
||||||
format_identity_hint,
|
format_identity_hint,
|
||||||
format_memory_context,
|
format_memory_context,
|
||||||
@@ -64,8 +66,10 @@ class ChatService:
|
|||||||
projects_snapshot = get_projects_snapshot(self.db)
|
projects_snapshot = get_projects_snapshot(self.db)
|
||||||
return (
|
return (
|
||||||
f"{self.character.get_system_prompt()}\n\n"
|
f"{self.character.get_system_prompt()}\n\n"
|
||||||
|
f"{format_datetime_context(self.db)}\n\n"
|
||||||
f"{format_memory_context(memory_snapshot)}\n\n"
|
f"{format_memory_context(memory_snapshot)}\n\n"
|
||||||
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
f"{format_fitness_context(fitness_snapshot)}\n\n"
|
||||||
|
f"{format_weather_snapshot()}\n\n"
|
||||||
f"{format_pomodoro_context(status)}\n\n"
|
f"{format_pomodoro_context(status)}\n\n"
|
||||||
f"{format_projects_context(projects_snapshot)}"
|
f"{format_projects_context(projects_snapshot)}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,6 +40,54 @@ class Settings(BaseSettings):
|
|||||||
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
|
openfoodfacts_base_url: str = "https://world.openfoodfacts.org"
|
||||||
fitness_reminders_enabled: bool = True
|
fitness_reminders_enabled: bool = True
|
||||||
|
|
||||||
|
openmeteo_base_url: str = "http://192.168.1.109:8085"
|
||||||
|
weather_lat: float = 59.9343
|
||||||
|
weather_lon: float = 30.3351
|
||||||
|
weather_location_name: str = "Санкт-Петербург"
|
||||||
|
weather_cache_sec: int = 300
|
||||||
|
|
||||||
|
news_rss_urls: str = (
|
||||||
|
"https://habr.com/ru/rss/all/all/,"
|
||||||
|
"https://www.reddit.com/r/programming/.rss"
|
||||||
|
)
|
||||||
|
news_cache_sec: int = 1800
|
||||||
|
news_max_items: int = 7
|
||||||
|
|
||||||
|
morning_digest_enabled: bool = True
|
||||||
|
morning_digest_hour: int = 8
|
||||||
|
morning_digest_minute: int = 0
|
||||||
|
|
||||||
|
comfyui_base_url: str = "http://192.168.1.109:8188"
|
||||||
|
comfyui_enabled: bool = True
|
||||||
|
# Anima split-model (default): set UNET+CLIP+VAE, leave CHECKPOINT empty
|
||||||
|
comfyui_checkpoint: str = ""
|
||||||
|
comfyui_unet: str = "anima-preview3-base.safetensors"
|
||||||
|
comfyui_clip: str = "qwen_3_06b_base.safetensors"
|
||||||
|
comfyui_vae: str = "qwen_image_vae.safetensors"
|
||||||
|
comfyui_style_lora: str = "anima-preview-3-masterpieces-v5.safetensors"
|
||||||
|
comfyui_style_lora_weight: float = 0.7
|
||||||
|
comfyui_steps: int = 30
|
||||||
|
comfyui_cfg: float = 4.0
|
||||||
|
comfyui_sampler: str = "er_sde"
|
||||||
|
comfyui_scheduler: str = "simple"
|
||||||
|
comfyui_width: int = 1024
|
||||||
|
comfyui_height: int = 720
|
||||||
|
comfyui_negative_prompt: str = (
|
||||||
|
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
|
||||||
|
)
|
||||||
|
comfyui_poll_interval_sec: float = 2.0
|
||||||
|
comfyui_timeout_sec: float = 180.0
|
||||||
|
comfyui_rofl_enabled: bool = True
|
||||||
|
comfyui_rofl_max_per_day: int = 1
|
||||||
|
comfyui_rofl_probability: float = 0.15
|
||||||
|
comfyui_rofl_min_interval_hours: int = 12
|
||||||
|
generated_media_dir: str = "./data/generated"
|
||||||
|
|
||||||
|
netdata_base_url: str = "http://host.docker.internal:19999"
|
||||||
|
netdata_public_url: str = ""
|
||||||
|
netdata_alerts_enabled: bool = True
|
||||||
|
netdata_poll_interval_sec: int = 120
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins_list(self) -> list[str]:
|
def cors_origins_list(self) -> list[str]:
|
||||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
@@ -52,6 +100,10 @@ class Settings(BaseSettings):
|
|||||||
def gitea_configured(self) -> bool:
|
def gitea_configured(self) -> bool:
|
||||||
return bool(self.gitea_token)
|
return bool(self.gitea_token)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def news_rss_urls_list(self) -> list[str]:
|
||||||
|
return [u.strip() for u in self.news_rss_urls.split(",") if u.strip()]
|
||||||
|
|
||||||
def load_system_prompt(self) -> str:
|
def load_system_prompt(self) -> str:
|
||||||
path = Path(self.system_prompt_path)
|
path = Path(self.system_prompt_path)
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
|
|||||||
@@ -215,6 +215,16 @@ class FitnessReminder(Base):
|
|||||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantState(Base):
|
||||||
|
__tablename__ = "assistant_state"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WorkItem(Base):
|
class WorkItem(Base):
|
||||||
__tablename__ = "work_items"
|
__tablename__ = "work_items"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
ANIMA_QUALITY_PREFIX = "masterpiece, best quality, score_7, anime"
|
||||||
|
ANIMA_DEFAULT_NEGATIVE = (
|
||||||
|
"worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
|
||||||
|
)
|
||||||
|
|
||||||
|
ROFL_PROMPTS = [
|
||||||
|
f"{ANIMA_QUALITY_PREFIX}, confused cat in tiny business suit, server room, meme, chibi",
|
||||||
|
f"{ANIMA_QUALITY_PREFIX}, potato with sunglasses on skateboard, absurd cartoon, silly",
|
||||||
|
f"{ANIMA_QUALITY_PREFIX}, astronaut watering houseplant on the moon, wholesome, cute",
|
||||||
|
f"{ANIMA_QUALITY_PREFIX}, rubber duck as judge at programming contest, comic style",
|
||||||
|
f"{ANIMA_QUALITY_PREFIX}, llama DJ at house party, neon lights, party, silly",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _use_anima(settings) -> bool:
|
||||||
|
return bool(settings.comfyui_unet.strip()) and not settings.comfyui_checkpoint.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_anima_workflow(
|
||||||
|
positive: str,
|
||||||
|
negative: str,
|
||||||
|
seed: int,
|
||||||
|
settings,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
workflow: dict[str, Any] = {
|
||||||
|
"44": {
|
||||||
|
"class_type": "UNETLoader",
|
||||||
|
"inputs": {"unet_name": settings.comfyui_unet, "weight_dtype": "default"},
|
||||||
|
},
|
||||||
|
"45": {
|
||||||
|
"class_type": "CLIPLoader",
|
||||||
|
"inputs": {
|
||||||
|
"clip_name": settings.comfyui_clip,
|
||||||
|
"type": "stable_diffusion",
|
||||||
|
"device": "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"class_type": "VAELoader",
|
||||||
|
"inputs": {"vae_name": settings.comfyui_vae},
|
||||||
|
},
|
||||||
|
"28": {
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"inputs": {
|
||||||
|
"width": settings.comfyui_width,
|
||||||
|
"height": settings.comfyui_height,
|
||||||
|
"batch_size": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": positive, "clip": ["45", 0]},
|
||||||
|
},
|
||||||
|
"12": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": negative, "clip": ["45", 0]},
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"model": ["44", 0],
|
||||||
|
"positive": ["11", 0],
|
||||||
|
"negative": ["12", 0],
|
||||||
|
"latent_image": ["28", 0],
|
||||||
|
"seed": seed,
|
||||||
|
"steps": settings.comfyui_steps,
|
||||||
|
"cfg": settings.comfyui_cfg,
|
||||||
|
"sampler_name": settings.comfyui_sampler,
|
||||||
|
"scheduler": settings.comfyui_scheduler,
|
||||||
|
"denoise": 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"inputs": {"samples": ["19", 0], "vae": ["15", 0]},
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"inputs": {"filename_prefix": "assistant", "images": ["8", 0]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
lora = settings.comfyui_style_lora.strip()
|
||||||
|
if lora:
|
||||||
|
workflow["46"] = {
|
||||||
|
"class_type": "LoraLoader",
|
||||||
|
"inputs": {
|
||||||
|
"lora_name": lora,
|
||||||
|
"model": ["44", 0],
|
||||||
|
"clip": ["45", 0],
|
||||||
|
"strength_model": settings.comfyui_style_lora_weight,
|
||||||
|
"strength_clip": settings.comfyui_style_lora_weight,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
workflow["19"]["inputs"]["model"] = ["46", 0]
|
||||||
|
workflow["11"]["inputs"]["clip"] = ["46", 1]
|
||||||
|
workflow["12"]["inputs"]["clip"] = ["46", 1]
|
||||||
|
|
||||||
|
return workflow
|
||||||
|
|
||||||
|
|
||||||
|
def _build_checkpoint_workflow(
|
||||||
|
positive: str,
|
||||||
|
negative: str,
|
||||||
|
seed: int,
|
||||||
|
settings,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"4": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {"ckpt_name": settings.comfyui_checkpoint},
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"inputs": {
|
||||||
|
"width": settings.comfyui_width,
|
||||||
|
"height": settings.comfyui_height,
|
||||||
|
"batch_size": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": positive, "clip": ["4", 1]},
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": negative, "clip": ["4", 1]},
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"model": ["4", 0],
|
||||||
|
"positive": ["6", 0],
|
||||||
|
"negative": ["7", 0],
|
||||||
|
"latent_image": ["5", 0],
|
||||||
|
"seed": seed,
|
||||||
|
"steps": settings.comfyui_steps,
|
||||||
|
"cfg": settings.comfyui_cfg,
|
||||||
|
"sampler_name": settings.comfyui_sampler,
|
||||||
|
"scheduler": settings.comfyui_scheduler,
|
||||||
|
"denoise": 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"class_type": "VAEDecode",
|
||||||
|
"inputs": {"samples": ["10", 0], "vae": ["4", 2]},
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"class_type": "SaveImage",
|
||||||
|
"inputs": {"filename_prefix": "assistant", "images": ["8", 0]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_workflow(positive: str, negative: str, seed: int, settings) -> dict[str, Any]:
|
||||||
|
if _use_anima(settings):
|
||||||
|
return _build_anima_workflow(positive, negative, seed, settings)
|
||||||
|
return _build_checkpoint_workflow(positive, negative, seed, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_positive_prompt(prompt: str, settings) -> str:
|
||||||
|
text = prompt.strip()
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
if _use_anima(settings) and ANIMA_QUALITY_PREFIX.lower() not in text.lower():
|
||||||
|
return f"{ANIMA_QUALITY_PREFIX}, {text}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class ComfyUIClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.base_url = settings.comfyui_base_url.rstrip("/")
|
||||||
|
self.enabled = settings.comfyui_enabled
|
||||||
|
self.settings = settings
|
||||||
|
self.output_dir = Path(settings.generated_media_dir)
|
||||||
|
self.poll_interval = settings.comfyui_poll_interval_sec
|
||||||
|
self.timeout = settings.comfyui_timeout_sec
|
||||||
|
|
||||||
|
def _default_negative(self) -> str:
|
||||||
|
if _use_anima(self.settings):
|
||||||
|
return self.settings.comfyui_negative_prompt or ANIMA_DEFAULT_NEGATIVE
|
||||||
|
return self.settings.comfyui_negative_prompt
|
||||||
|
|
||||||
|
def _ensure_output_dir(self) -> None:
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
async def generate_image(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
*,
|
||||||
|
negative_prompt: str | None = None,
|
||||||
|
seed: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not self.enabled:
|
||||||
|
return {"ok": False, "error": "ComfyUI отключён (COMFYUI_ENABLED=false)"}
|
||||||
|
|
||||||
|
if not _use_anima(self.settings) and not self.settings.comfyui_checkpoint.strip():
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Не задан COMFYUI_UNET (Anima) или COMFYUI_CHECKPOINT",
|
||||||
|
}
|
||||||
|
|
||||||
|
self._ensure_output_dir()
|
||||||
|
seed = seed if seed is not None else random.randint(1, 2**31 - 1)
|
||||||
|
positive = _wrap_positive_prompt(prompt, self.settings)
|
||||||
|
negative = negative_prompt or self._default_negative()
|
||||||
|
workflow = _build_workflow(positive, negative, seed, self.settings)
|
||||||
|
client_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/prompt",
|
||||||
|
json={"prompt": workflow, "client_id": client_id},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return {"ok": False, "error": f"ComfyUI prompt error: {response.text[:300]}"}
|
||||||
|
prompt_id = response.json().get("prompt_id")
|
||||||
|
if not prompt_id:
|
||||||
|
return {"ok": False, "error": "ComfyUI не вернул prompt_id"}
|
||||||
|
|
||||||
|
elapsed = 0.0
|
||||||
|
while elapsed < self.timeout:
|
||||||
|
await asyncio.sleep(self.poll_interval)
|
||||||
|
elapsed += self.poll_interval
|
||||||
|
hist_resp = await client.get(f"{self.base_url}/history/{prompt_id}")
|
||||||
|
if hist_resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
history = hist_resp.json()
|
||||||
|
if prompt_id not in history:
|
||||||
|
continue
|
||||||
|
entry = history[prompt_id]
|
||||||
|
status = (entry.get("status") or {}).get("status_str")
|
||||||
|
if status == "error":
|
||||||
|
msgs = entry.get("status", {}).get("messages", [])
|
||||||
|
return {"ok": False, "error": f"ComfyUI workflow error: {msgs}"}
|
||||||
|
|
||||||
|
outputs = entry.get("outputs") or {}
|
||||||
|
for node_output in outputs.values():
|
||||||
|
images = node_output.get("images") or []
|
||||||
|
if not images:
|
||||||
|
continue
|
||||||
|
image_info = images[0]
|
||||||
|
view_params = {
|
||||||
|
"filename": image_info["filename"],
|
||||||
|
"subfolder": image_info.get("subfolder", ""),
|
||||||
|
"type": image_info.get("type", "output"),
|
||||||
|
}
|
||||||
|
img_resp = await client.get(f"{self.base_url}/view", params=view_params)
|
||||||
|
if img_resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
filename = f"{uuid.uuid4().hex}.png"
|
||||||
|
out_path = self.output_dir / filename
|
||||||
|
out_path.write_bytes(img_resp.content)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"filename": filename,
|
||||||
|
"url": f"/api/v1/media/generated/{filename}",
|
||||||
|
"prompt": positive,
|
||||||
|
"backend": "anima" if _use_anima(self.settings) else "checkpoint",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"ok": False, "error": f"Таймаут генерации ({self.timeout}s)"}
|
||||||
|
|
||||||
|
def random_rofl_prompt(self) -> str:
|
||||||
|
return random.choice(ROFL_PROMPTS)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.memory.service import MemoryService
|
||||||
|
|
||||||
|
WEEKDAY_RU = (
|
||||||
|
"понедельник",
|
||||||
|
"вторник",
|
||||||
|
"среда",
|
||||||
|
"четверг",
|
||||||
|
"пятница",
|
||||||
|
"суббота",
|
||||||
|
"воскресенье",
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_TIMEZONE = "Europe/Moscow"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_timezone(db: Session) -> str:
|
||||||
|
profile = MemoryService(db).get_profile()
|
||||||
|
tz = (profile.get("timezone") or "").strip()
|
||||||
|
return tz or DEFAULT_TIMEZONE
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime_context(db: Session) -> str:
|
||||||
|
tz_name = resolve_timezone(db)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = ZoneInfo(DEFAULT_TIMEZONE)
|
||||||
|
tz_name = DEFAULT_TIMEZONE
|
||||||
|
|
||||||
|
now = datetime.now(tz)
|
||||||
|
weekday = WEEKDAY_RU[now.weekday()]
|
||||||
|
lines = [
|
||||||
|
"[Текущее время]",
|
||||||
|
f"Сейчас: {now.strftime('%Y-%m-%d %H:%M')} ({weekday}), часовой пояс {tz_name}.",
|
||||||
|
"Учитывай время при ответах о «сегодня», «утром», «вечером» и расписании.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.homelab.openmeteo import OpenMeteoClient, format_weather_snapshot
|
||||||
|
from app.homelab.rss import RssClient
|
||||||
|
|
||||||
|
|
||||||
|
def build_morning_digest(db: Session, *, include_news: bool = True) -> str:
|
||||||
|
del db # timezone resolved via weather client / profile in future extensions
|
||||||
|
weather_client = OpenMeteoClient()
|
||||||
|
weather = weather_client.fetch_current_and_hourly(hours_ahead=12)
|
||||||
|
|
||||||
|
lines = ["🌤 **Утренний дайджест**", ""]
|
||||||
|
|
||||||
|
if weather.get("ok"):
|
||||||
|
cur = weather.get("current") or {}
|
||||||
|
lines.append(
|
||||||
|
f"**Погода ({weather.get('location')})**: "
|
||||||
|
f"{cur.get('temperature_c')}°C, {cur.get('conditions')}, "
|
||||||
|
f"ветер {cur.get('wind_speed_kmh')} км/ч."
|
||||||
|
)
|
||||||
|
lines.append(weather_client.rain_summary(hours_ahead=12))
|
||||||
|
else:
|
||||||
|
lines.append(f"**Погода**: недоступна ({weather.get('error', 'ошибка')}).")
|
||||||
|
|
||||||
|
if include_news:
|
||||||
|
headlines = RssClient().fetch_headlines(limit=7)
|
||||||
|
lines.append("")
|
||||||
|
if headlines:
|
||||||
|
lines.append("**Новости:**")
|
||||||
|
for item in headlines:
|
||||||
|
title = item.get("title", "")
|
||||||
|
link = item.get("link", "")
|
||||||
|
source = item.get("source", "")
|
||||||
|
if link:
|
||||||
|
lines.append(f"- [{title}]({link}) — {source}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- {title} — {source}")
|
||||||
|
else:
|
||||||
|
lines.append("**Новости**: ленты временно недоступны.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_weather_briefing(hours_ahead: int = 12, include_news: bool = False) -> dict:
|
||||||
|
client = OpenMeteoClient()
|
||||||
|
weather = client.fetch_current_and_hourly(hours_ahead=hours_ahead)
|
||||||
|
result = {
|
||||||
|
"weather": weather,
|
||||||
|
"rain_summary": client.rain_summary(hours_ahead=hours_ahead) if weather.get("ok") else "",
|
||||||
|
"context": format_weather_snapshot(weather),
|
||||||
|
}
|
||||||
|
if include_news:
|
||||||
|
result["news"] = RssClient().fetch_headlines(limit=7)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.homelab.netdata import NetdataClient
|
||||||
|
from app.homelab.state import get_state, set_state
|
||||||
|
|
||||||
|
ALERT_COOLDOWN_SEC = 1800
|
||||||
|
|
||||||
|
|
||||||
|
def _alarm_key(alarm: dict[str, Any]) -> str:
|
||||||
|
return f"{alarm.get('name')}:{alarm.get('status')}"
|
||||||
|
|
||||||
|
|
||||||
|
def check_netdata_alerts(db: Session) -> list[str]:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.netdata_alerts_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = NetdataClient().fetch_alarms()
|
||||||
|
if not result.get("ok"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
alarms = result.get("alarms") or []
|
||||||
|
significant = [
|
||||||
|
a for a in alarms
|
||||||
|
if (a.get("status") or "").lower() in ("warning", "critical", "raised")
|
||||||
|
]
|
||||||
|
if not significant:
|
||||||
|
return []
|
||||||
|
|
||||||
|
prev_raw = get_state(db, "netdata_alarm_hashes") or "{}"
|
||||||
|
try:
|
||||||
|
prev_map: dict[str, float] = json.loads(prev_raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
prev_map = {}
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
notices: list[str] = []
|
||||||
|
new_map = dict(prev_map)
|
||||||
|
|
||||||
|
for alarm in significant:
|
||||||
|
key = _alarm_key(alarm)
|
||||||
|
digest = hashlib.sha256(json.dumps(alarm, sort_keys=True).encode()).hexdigest()[:16]
|
||||||
|
state_key = f"netdata:{key}:{digest}"
|
||||||
|
last_sent = prev_map.get(state_key, 0)
|
||||||
|
if now - last_sent < ALERT_COOLDOWN_SEC:
|
||||||
|
continue
|
||||||
|
|
||||||
|
host = alarm.get("host") or "server"
|
||||||
|
value = alarm.get("value_string") or ""
|
||||||
|
info = alarm.get("info") or alarm.get("name") or "алерт"
|
||||||
|
status = alarm.get("status") or "alert"
|
||||||
|
link = settings.netdata_public_url
|
||||||
|
link_part = f" [Netdata]({link})" if link else ""
|
||||||
|
notices.append(
|
||||||
|
f"⚠️ **Netdata** · {host}: {info} — {status}"
|
||||||
|
+ (f" ({value})" if value else "")
|
||||||
|
+ link_part
|
||||||
|
)
|
||||||
|
new_map[state_key] = now
|
||||||
|
|
||||||
|
if notices:
|
||||||
|
set_state(db, "netdata_alarm_hashes", json.dumps(new_map))
|
||||||
|
|
||||||
|
return notices
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class NetdataClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.base_url = settings.netdata_base_url.rstrip("/")
|
||||||
|
self.enabled = settings.netdata_alerts_enabled
|
||||||
|
|
||||||
|
def fetch_alarms(self) -> dict[str, Any]:
|
||||||
|
if not self.enabled:
|
||||||
|
return {"ok": False, "error": "Netdata alerts disabled", "alarms": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15.0) as client:
|
||||||
|
response = client.get(f"{self.base_url}/api/v1/alarms", params={"all": "true"})
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc), "alarms": []}
|
||||||
|
|
||||||
|
alarms_raw = data.get("alarms") or {}
|
||||||
|
alarms: list[dict[str, Any]] = []
|
||||||
|
if isinstance(alarms_raw, dict):
|
||||||
|
for name, info in alarms_raw.items():
|
||||||
|
if not isinstance(info, dict):
|
||||||
|
continue
|
||||||
|
status = (info.get("status") or "").lower()
|
||||||
|
if status in ("clear", "undefined", "uninitialized", ""):
|
||||||
|
continue
|
||||||
|
alarms.append({
|
||||||
|
"name": name,
|
||||||
|
"status": status,
|
||||||
|
"value_string": info.get("value_string") or info.get("value") or "",
|
||||||
|
"chart": info.get("chart") or "",
|
||||||
|
"host": info.get("hostname") or info.get("host") or "localhost",
|
||||||
|
"info": info.get("info") or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"ok": True, "alarms": alarms}
|
||||||
|
|
||||||
|
def fetch_info(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
response = client.get(f"{self.base_url}/api/v1/info")
|
||||||
|
response.raise_for_status()
|
||||||
|
return {"ok": True, "info": response.json()}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.db.models import ChatSession, Message
|
||||||
|
|
||||||
|
|
||||||
|
def post_chat_notice(content: str) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
session = db.scalar(
|
||||||
|
select(ChatSession).order_by(ChatSession.updated_at.desc()).limit(1)
|
||||||
|
)
|
||||||
|
if not session:
|
||||||
|
session = ChatSession(title="Уведомления")
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(session)
|
||||||
|
db.add(Message(session_id=session.id, role="notice", content=content))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
WEATHER_CODES: dict[int, str] = {
|
||||||
|
0: "ясно",
|
||||||
|
1: "преимущественно ясно",
|
||||||
|
2: "переменная облачность",
|
||||||
|
3: "пасмурно",
|
||||||
|
45: "туман",
|
||||||
|
48: "изморозь",
|
||||||
|
51: "морось",
|
||||||
|
53: "морось",
|
||||||
|
55: "морось",
|
||||||
|
61: "дождь",
|
||||||
|
63: "дождь",
|
||||||
|
65: "сильный дождь",
|
||||||
|
71: "снег",
|
||||||
|
73: "снег",
|
||||||
|
75: "сильный снег",
|
||||||
|
80: "ливень",
|
||||||
|
81: "ливень",
|
||||||
|
82: "сильный ливень",
|
||||||
|
95: "гроза",
|
||||||
|
96: "гроза с градом",
|
||||||
|
99: "гроза с градом",
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache: dict[str, Any] = {"data": None, "expires_at": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
class OpenMeteoClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.base_url = settings.openmeteo_base_url.rstrip("/")
|
||||||
|
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 _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()
|
||||||
|
|
||||||
|
_cache["data"] = data
|
||||||
|
_cache["expires_at"] = now + self.cache_ttl
|
||||||
|
return data
|
||||||
|
|
||||||
|
def fetch_current_and_hourly(self, hours_ahead: int = 12) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
raw = self._fetch_raw()
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc), "location": self.location_name}
|
||||||
|
|
||||||
|
current = raw.get("current") or {}
|
||||||
|
hourly = raw.get("hourly") or {}
|
||||||
|
times = hourly.get("time") or []
|
||||||
|
limit = min(hours_ahead, len(times))
|
||||||
|
hourly_slice = []
|
||||||
|
for i in range(limit):
|
||||||
|
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],
|
||||||
|
})
|
||||||
|
|
||||||
|
code = current.get("weather_code")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"location": self.location_name,
|
||||||
|
"current": {
|
||||||
|
"time": current.get("time"),
|
||||||
|
"temperature_c": current.get("temperature_2m"),
|
||||||
|
"apparent_temperature_c": current.get("apparent_temperature"),
|
||||||
|
"humidity_pct": current.get("relative_humidity_2m"),
|
||||||
|
"precipitation_mm": current.get("precipitation"),
|
||||||
|
"wind_speed_kmh": current.get("wind_speed_10m"),
|
||||||
|
"weather_code": code,
|
||||||
|
"conditions": WEATHER_CODES.get(code, "неизвестно") if code is not None else "неизвестно",
|
||||||
|
},
|
||||||
|
"hourly": hourly_slice,
|
||||||
|
}
|
||||||
|
|
||||||
|
def rain_summary(self, hours_ahead: int = 12) -> str:
|
||||||
|
data = self.fetch_current_and_hourly(hours_ahead=hours_ahead)
|
||||||
|
if not data.get("ok"):
|
||||||
|
return f"Погода недоступна: {data.get('error', 'ошибка')}"
|
||||||
|
|
||||||
|
rainy_hours = []
|
||||||
|
for hour in data.get("hourly") or []:
|
||||||
|
prob = hour.get("precipitation_probability")
|
||||||
|
precip = hour.get("precipitation_mm") or 0
|
||||||
|
if (prob is not None and prob >= 40) or precip > 0:
|
||||||
|
time_str = (hour.get("time") or "")[11:16]
|
||||||
|
rainy_hours.append(f"{time_str} ({prob}% вероятность, {precip} мм)")
|
||||||
|
|
||||||
|
if rainy_hours:
|
||||||
|
return "Ожидаются осадки: " + ", ".join(rainy_hours[:6])
|
||||||
|
return "Существенных осадков в ближайшие часы не ожидается."
|
||||||
|
|
||||||
|
|
||||||
|
def format_weather_snapshot(data: dict[str, Any] | None = None) -> str:
|
||||||
|
client = OpenMeteoClient()
|
||||||
|
snapshot = data if data is not None else client.fetch_current_and_hourly(hours_ahead=6)
|
||||||
|
|
||||||
|
lines = ["[Погода]"]
|
||||||
|
if not snapshot.get("ok"):
|
||||||
|
lines.append(f"Данные недоступны ({snapshot.get('error', 'ошибка')}).")
|
||||||
|
lines.append("Для точного ответа вызови get_weather.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
cur = snapshot.get("current") or {}
|
||||||
|
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')} км/ч."
|
||||||
|
)
|
||||||
|
lines.append(client.rain_summary(hours_ahead=6))
|
||||||
|
lines.append("Вопросы «что на улице» / «будет ли дождь» — get_weather.")
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
_cache: dict[str, Any] = {"items": [], "expires_at": 0.0}
|
||||||
|
|
||||||
|
|
||||||
|
class RssClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self.urls = settings.news_rss_urls_list
|
||||||
|
self.cache_ttl = settings.news_cache_sec
|
||||||
|
self.max_items = settings.news_max_items
|
||||||
|
|
||||||
|
def _fetch_feed(self, url: str) -> list[dict[str, str]]:
|
||||||
|
headers = {"User-Agent": "HomeAIAssistant/1.0 (+https://assistant.grigowashere.ru)"}
|
||||||
|
with httpx.Client(timeout=20.0, headers=headers, follow_redirects=True) as client:
|
||||||
|
response = client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
parsed = feedparser.parse(response.content)
|
||||||
|
|
||||||
|
source = urlparse(url).netloc or url
|
||||||
|
items: list[dict[str, str]] = []
|
||||||
|
for entry in parsed.entries[: self.max_items]:
|
||||||
|
link = (entry.get("link") or "").strip()
|
||||||
|
title = (entry.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"title": title,
|
||||||
|
"link": link,
|
||||||
|
"source": source,
|
||||||
|
"published": (entry.get("published") or entry.get("updated") or "").strip(),
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
def fetch_headlines(self, limit: int | None = None) -> list[dict[str, str]]:
|
||||||
|
now = time.time()
|
||||||
|
if _cache["items"] and now < _cache["expires_at"]:
|
||||||
|
items = _cache["items"]
|
||||||
|
else:
|
||||||
|
merged: list[dict[str, str]] = []
|
||||||
|
seen_links: set[str] = set()
|
||||||
|
for url in self.urls:
|
||||||
|
try:
|
||||||
|
for item in self._fetch_feed(url):
|
||||||
|
link = item.get("link") or item["title"]
|
||||||
|
if link in seen_links:
|
||||||
|
continue
|
||||||
|
seen_links.add(link)
|
||||||
|
merged.append(item)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
_cache["items"] = merged
|
||||||
|
_cache["expires_at"] = now + self.cache_ttl
|
||||||
|
items = merged
|
||||||
|
|
||||||
|
cap = limit or self.max_items
|
||||||
|
return items[:cap]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.models import AssistantState
|
||||||
|
|
||||||
|
|
||||||
|
def get_state(db: Session, key: str) -> str | None:
|
||||||
|
row = db.get(AssistantState, key)
|
||||||
|
return row.value if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_state(db: Session, key: str, value: str) -> None:
|
||||||
|
row = db.get(AssistantState, key)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if row:
|
||||||
|
row.value = value
|
||||||
|
row.updated_at = now
|
||||||
|
else:
|
||||||
|
db.add(AssistantState(key=key, value=value, updated_at=now))
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.homelab.comfyui import ComfyUIClient
|
||||||
|
from app.homelab.context import resolve_timezone
|
||||||
|
from app.homelab.digest import build_morning_digest
|
||||||
|
from app.homelab.monitoring import check_netdata_alerts
|
||||||
|
from app.homelab.notices import post_chat_notice
|
||||||
|
from app.homelab.state import get_state, set_state
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
WATCH_INTERVAL_SEC = 60
|
||||||
|
_netdata_tick = 0
|
||||||
|
|
||||||
|
|
||||||
|
async def homelab_watcher_loop() -> None:
|
||||||
|
global _netdata_tick
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(WATCH_INTERVAL_SEC)
|
||||||
|
await _tick_morning_digest()
|
||||||
|
await _tick_rofl()
|
||||||
|
settings = get_settings()
|
||||||
|
_netdata_tick += WATCH_INTERVAL_SEC
|
||||||
|
if _netdata_tick >= settings.netdata_poll_interval_sec:
|
||||||
|
_netdata_tick = 0
|
||||||
|
await _tick_netdata()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Homelab watcher error")
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick_morning_digest() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.morning_digest_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
tz_name = resolve_timezone(db)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = ZoneInfo("Europe/Moscow")
|
||||||
|
|
||||||
|
now = datetime.now(tz)
|
||||||
|
target_min = settings.morning_digest_hour * 60 + settings.morning_digest_minute
|
||||||
|
current_min = now.hour * 60 + now.minute
|
||||||
|
if current_min < target_min or current_min >= target_min + 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
today = now.date().isoformat()
|
||||||
|
if get_state(db, "last_morning_digest_date") == today:
|
||||||
|
return
|
||||||
|
|
||||||
|
digest = build_morning_digest(db, include_news=True)
|
||||||
|
post_chat_notice(digest)
|
||||||
|
set_state(db, "last_morning_digest_date", today)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick_netdata() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for notice in check_netdata_alerts(db):
|
||||||
|
post_chat_notice(notice)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _tick_rofl() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.comfyui_enabled or not settings.comfyui_rofl_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
tz_name = resolve_timezone(db)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
tz = ZoneInfo("Europe/Moscow")
|
||||||
|
now = datetime.now(tz)
|
||||||
|
last_raw = get_state(db, "last_comfy_rofl_at")
|
||||||
|
if last_raw:
|
||||||
|
try:
|
||||||
|
last_at = datetime.fromisoformat(last_raw)
|
||||||
|
if last_at.tzinfo is None:
|
||||||
|
last_at = last_at.replace(tzinfo=tz)
|
||||||
|
if (now - last_at).total_seconds() < settings.comfyui_rofl_min_interval_hours * 3600:
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if random.random() > settings.comfyui_rofl_probability:
|
||||||
|
return
|
||||||
|
|
||||||
|
today = now.date().isoformat()
|
||||||
|
|
||||||
|
count_raw = get_state(db, f"comfy_rofl_count_{today}") or "0"
|
||||||
|
try:
|
||||||
|
count = int(count_raw)
|
||||||
|
except ValueError:
|
||||||
|
count = 0
|
||||||
|
if count >= settings.comfyui_rofl_max_per_day:
|
||||||
|
return
|
||||||
|
|
||||||
|
client = ComfyUIClient()
|
||||||
|
prompt = client.random_rofl_prompt()
|
||||||
|
result = await client.generate_image(prompt)
|
||||||
|
if not result.get("ok"):
|
||||||
|
logger.warning("Rofl image failed: %s", result.get("error"))
|
||||||
|
return
|
||||||
|
|
||||||
|
url = result.get("url", "")
|
||||||
|
post_chat_notice(
|
||||||
|
f"🎨 **Рофл дня**\n\n\n\n_{prompt}_"
|
||||||
|
)
|
||||||
|
set_state(db, f"comfy_rofl_count_{today}", str(count + 1))
|
||||||
|
set_state(db, "last_comfy_rofl_at", now.isoformat())
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -8,6 +8,7 @@ from app.api.routes import api_router
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db.base import init_db
|
from app.db.base import init_db
|
||||||
from app.fitness.watcher import fitness_watcher_loop
|
from app.fitness.watcher import fitness_watcher_loop
|
||||||
|
from app.homelab.watcher import homelab_watcher_loop
|
||||||
from app.pomodoro.watcher import pomodoro_watcher_loop
|
from app.pomodoro.watcher import pomodoro_watcher_loop
|
||||||
|
|
||||||
|
|
||||||
@@ -16,13 +17,17 @@ async def lifespan(_: FastAPI):
|
|||||||
init_db()
|
init_db()
|
||||||
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
pomodoro_task = asyncio.create_task(pomodoro_watcher_loop())
|
||||||
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
fitness_task = asyncio.create_task(fitness_watcher_loop())
|
||||||
|
homelab_task = asyncio.create_task(homelab_watcher_loop())
|
||||||
yield
|
yield
|
||||||
pomodoro_task.cancel()
|
pomodoro_task.cancel()
|
||||||
fitness_task.cancel()
|
fitness_task.cancel()
|
||||||
|
homelab_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await pomodoro_task
|
await pomodoro_task
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await fitness_task
|
await fitness_task
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await homelab_task
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.fitness.service import FitnessService
|
from app.fitness.service import FitnessService
|
||||||
from app.fitness.structuring import structure_meal, structure_workout
|
from app.fitness.structuring import structure_meal, structure_workout
|
||||||
|
from app.homelab.comfyui import ComfyUIClient
|
||||||
|
from app.homelab.digest import build_weather_briefing
|
||||||
|
from app.homelab.openmeteo import OpenMeteoClient
|
||||||
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
from app.integrations.openfoodfacts import OpenFoodFactsClient
|
||||||
from app.integrations.wger import WgerClient
|
from app.integrations.wger import WgerClient
|
||||||
from app.memory.service import MemoryService
|
from app.memory.service import MemoryService
|
||||||
@@ -430,6 +433,61 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": (
|
||||||
|
"ОБЯЗАТЕЛЬНО для вопросов о погоде, «что на улице», «будет ли дождь». "
|
||||||
|
"Текущая погода и прогноз по часам."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"hours_ahead": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Сколько часов прогноза (по умолчанию 12)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_morning_briefing",
|
||||||
|
"description": "Утренний брифинг: погода и заголовки новостей.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"include_news": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Включить новости (по умолчанию true)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "generate_image",
|
||||||
|
"description": (
|
||||||
|
"Сгенерировать картинку через ComfyUI на домашнем GPU. "
|
||||||
|
"Только по явному запросу или редко по рофлу."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": {"type": "string", "description": "Описание картинки на английском"},
|
||||||
|
"negative_prompt": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["prompt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -593,6 +651,25 @@ async def execute_tool(
|
|||||||
minute=arguments.get("minute"),
|
minute=arguments.get("minute"),
|
||||||
interval_hours=arguments.get("interval_hours"),
|
interval_hours=arguments.get("interval_hours"),
|
||||||
)
|
)
|
||||||
|
elif name == "get_weather":
|
||||||
|
hours = int(arguments.get("hours_ahead") or 12)
|
||||||
|
client = OpenMeteoClient()
|
||||||
|
weather = client.fetch_current_and_hourly(hours_ahead=hours)
|
||||||
|
result = {
|
||||||
|
"weather": weather,
|
||||||
|
"rain_summary": client.rain_summary(hours_ahead=hours) if weather.get("ok") else "",
|
||||||
|
}
|
||||||
|
elif name == "get_morning_briefing":
|
||||||
|
include_news = arguments.get("include_news", True)
|
||||||
|
result = build_weather_briefing(
|
||||||
|
hours_ahead=12,
|
||||||
|
include_news=bool(include_news),
|
||||||
|
)
|
||||||
|
elif name == "generate_image":
|
||||||
|
result = await ComfyUIClient().generate_image(
|
||||||
|
arguments.get("prompt", ""),
|
||||||
|
negative_prompt=arguments.get("negative_prompt"),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,10 @@
|
|||||||
- «Что ты помнишь» → recall_memories или факты из контекста
|
- «Что ты помнишь» → recall_memories или факты из контекста
|
||||||
- Имя, часовой пояс → update_profile
|
- Имя, часовой пояс → update_profile
|
||||||
- Не выдумывай факты о пользователе
|
- Не выдумывай факты о пользователе
|
||||||
|
|
||||||
|
Стиль:
|
||||||
|
- В ответах пользователю не используй эмодзи
|
||||||
|
|
||||||
|
Погода и дайджест:
|
||||||
|
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
|
||||||
|
- Утренний брифинг — get_morning_briefing
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ openai>=1.55.0
|
|||||||
python-dotenv>=1.0.1
|
python-dotenv>=1.0.1
|
||||||
aiosqlite>=0.20.0
|
aiosqlite>=0.20.0
|
||||||
httpx>=0.28.0
|
httpx>=0.28.0
|
||||||
|
feedparser>=6.0.11
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ function noticeLabel(content: string): string {
|
|||||||
if (content.startsWith("🔀")) return "git";
|
if (content.startsWith("🔀")) return "git";
|
||||||
if (content.startsWith("🧠")) return "память";
|
if (content.startsWith("🧠")) return "память";
|
||||||
if (content.startsWith("💪")) return "фитнес";
|
if (content.startsWith("💪")) return "фитнес";
|
||||||
|
if (content.startsWith("🌤")) return "погода";
|
||||||
|
if (content.startsWith("🎨")) return "картинка";
|
||||||
|
if (content.startsWith("⚠️")) return "сервер";
|
||||||
return "система";
|
return "система";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user