diff --git a/.env.example b/.env.example index 36551ce..b5e8e4c 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,11 @@ COMFYUI_ROFL_PROBABILITY=0.15 COMFYUI_ROFL_MIN_INTERVAL_HOURS=12 GENERATED_MEDIA_DIR=./data/generated +# RP Chat (aiChatBot) — генерация картинок + sd-prompt; persona_id в карточке персонажа +RP_CHAT_BASE_URL=http://host.docker.internal:8201 +RP_CHAT_ENABLED=true +RP_CHAT_TIMEOUT_SEC=300 + # Netdata on server NETDATA_BASE_URL=http://host.docker.internal:19999 NETDATA_PUBLIC_URL= diff --git a/README.md b/README.md index 328c647..defc912 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,8 @@ lookup wger + Open Food Facts, напоминания в чат (`💪`), вкл | Сервис | URL по умолчанию | Назначение | |--------|------------------|------------| | Open-Meteo | `http://192.168.1.109:8085` | Погода СПб в контексте и tool `get_weather` | -| ComfyUI | `http://192.168.1.109:8188` | `generate_image`, редкий «рофл» в чат | +| ComfyUI | `http://192.168.1.109:8188` | fallback / рофл-watcher | +| RP Chat (aiChatBot) | `http://host.docker.internal:8201` | `generate_image`: sd-prompt + Anima; appearance в `/character` | | Netdata | `http://host.docker.internal:19999` | Алерты warning/critical → notice в чат | **Утренний дайджест** (`MORNING_DIGEST_HOUR=8`): погода + RSS (Habr, r/programming по умолчанию). diff --git a/backend/app/api/routes/character.py b/backend/app/api/routes/character.py index d20d2b9..448bdaa 100644 --- a/backend/app/api/routes/character.py +++ b/backend/app/api/routes/character.py @@ -22,6 +22,12 @@ class CharacterCardData(BaseModel): creator_notes: str = "" alternate_greetings: list[str] = Field(default_factory=list) character_version: str = "1.0" + appearance_tags: str = "" + appearance_prose: str = "" + lora_name: str = "" + lora_weight: float = 0.8 + rp_persona_id: str = "" + sd_enabled: bool = True class CharacterCardV2(BaseModel): diff --git a/backend/app/api/routes/homelab.py b/backend/app/api/routes/homelab.py index 352020e..3e881e2 100644 --- a/backend/app/api/routes/homelab.py +++ b/backend/app/api/routes/homelab.py @@ -29,11 +29,14 @@ def homelab_status() -> dict: "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"), + "rp_chat": _probe(f"{settings.rp_chat_base_url.rstrip('/')}/health"), "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, + "rp_chat_base_url": settings.rp_chat_base_url, + "rp_chat_enabled": settings.rp_chat_enabled, }, } diff --git a/backend/app/character/card.py b/backend/app/character/card.py index 34c463d..69d3983 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -22,7 +22,7 @@ TOOLS_INSTRUCTIONS = """ - В текстовых ответах пользователю не используй эмодзи. - Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай. - Утренний брифинг (погода + новости) → get_morning_briefing. -- Картинки: generate_image (ComfyUI Anima) — промпт на английском, booru-теги + короткое описание; не злоупотребляй. +- Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй. """.strip() DEFAULT_CARD: dict[str, Any] = { @@ -42,6 +42,12 @@ DEFAULT_CARD: dict[str, Any] = { "creator": "", "creator_notes": "", "character_version": "1.0", + "appearance_tags": "", + "appearance_prose": "", + "lora_name": "", + "lora_weight": 0.8, + "rp_persona_id": "", + "sd_enabled": True, }, } diff --git a/backend/app/config.py b/backend/app/config.py index 9dcb613..326831f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -88,6 +88,10 @@ class Settings(BaseSettings): netdata_alerts_enabled: bool = True netdata_poll_interval_sec: int = 120 + rp_chat_base_url: str = "http://host.docker.internal:8201" + rp_chat_enabled: bool = True + rp_chat_timeout_sec: float = 300.0 + @property def cors_origins_list(self) -> list[str]: return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] diff --git a/backend/app/homelab/image_gen.py b/backend/app/homelab/image_gen.py new file mode 100644 index 0000000..3320386 --- /dev/null +++ b/backend/app/homelab/image_gen.py @@ -0,0 +1,130 @@ +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.character.service import CharacterService +from app.config import get_settings +from app.db.models import Message +from app.homelab.comfyui import ComfyUIClient +from app.integrations.rp_chat import RpChatClient + + +def _card_image_settings() -> dict[str, Any]: + return CharacterService().get_card().get("data", {}) + + +def _session_messages(db: Session, session_id: int | None, limit: int = 8) -> list[dict[str, str]]: + if not session_id: + return [] + rows = db.scalars( + select(Message) + .where( + Message.session_id == session_id, + Message.role.in_(("user", "assistant")), + ) + .order_by(Message.created_at.desc()) + .limit(limit) + ).all() + rows = list(reversed(rows)) + return [{"role": m.role, "content": (m.content or "").strip()} for m in rows if m.content.strip()] + + +def _append_lora(positive: str, lora_name: str, lora_weight: float) -> str: + if not lora_name or f"" + + +async def generate_image( + db: Session, + *, + session_id: int | None = None, + draw_self: bool = False, + scene_description: str = "", +) -> dict[str, Any]: + card = _card_image_settings() + settings = get_settings() + + if not card.get("sd_enabled", True): + return {"ok": False, "error": "Генерация изображений отключена в настройках персонажа"} + + if not draw_self and not scene_description.strip(): + return {"ok": False, "error": "Нужен draw_self=true или scene_description"} + + appearance = (card.get("appearance_tags") or "").strip() + if draw_self and not appearance: + return { + "ok": False, + "error": "Заполни appearance_tags в настройках персонажа для «нарисуй себя»", + } + + messages = _session_messages(db, session_id) + if scene_description.strip(): + messages = messages + [{"role": "user", "content": scene_description.strip()}] + elif draw_self and messages: + messages = messages + [{"role": "user", "content": "Illustrate the current scene with the character."}] + elif draw_self: + messages = [{"role": "user", "content": "Portrait of the character, looking at viewer, friendly expression."}] + + if settings.rp_chat_enabled: + appearance_override = (card.get("appearance_tags") or "").strip() or None + return await _generate_via_rp_chat(card, messages, appearance_override) + + return await _generate_via_local_comfy(scene_description or "anime character portrait") + + +async def _generate_via_rp_chat( + card: dict[str, Any], + messages: list[dict[str, str]], + appearance_override: str | None, +) -> dict[str, Any]: + client = RpChatClient() + persona_id = (card.get("rp_persona_id") or "").strip() or "default" + override = appearance_override or (card.get("appearance_tags") or "").strip() or None + + prompt_result = await client.sd_prompt( + persona_id, + messages, + appearance_override=override, + ) + if not prompt_result.get("ok"): + return prompt_result + + positive = ( + prompt_result.get("hybrid_positive") + or prompt_result.get("tag_positive") + or "" + ).strip() + negative = (prompt_result.get("negative") or "").strip() + if not positive: + return {"ok": False, "error": "RP-чат не вернул промпт", "raw": prompt_result} + + lora = (card.get("lora_name") or "").strip() + if lora: + weight = float(card.get("lora_weight") or 0.8) + positive = _append_lora(positive, lora, weight) + + gen_result = await client.generate(positive, negative) + if not gen_result.get("ok"): + return gen_result + + saved = await client.save_image_locally(gen_result["image_path"]) + if not saved.get("ok"): + return saved + + return { + "ok": True, + "url": saved["url"], + "filename": saved["filename"], + "prompt": positive, + "backend": "rp_chat", + "persona_id": persona_id, + } + + +async def _generate_via_local_comfy(prompt: str) -> dict[str, Any]: + result = await ComfyUIClient().generate_image(prompt) + if result.get("ok"): + result["backend"] = "comfyui_local" + return result diff --git a/backend/app/integrations/rp_chat.py b/backend/app/integrations/rp_chat.py new file mode 100644 index 0000000..e1b68ed --- /dev/null +++ b/backend/app/integrations/rp_chat.py @@ -0,0 +1,89 @@ +import logging +import uuid +from pathlib import Path +from typing import Any + +import httpx + +from app.config import get_settings + +logger = logging.getLogger(__name__) + + +class RpChatClient: + def __init__(self) -> None: + settings = get_settings() + self.base_url = settings.rp_chat_base_url.rstrip("/") + self.enabled = settings.rp_chat_enabled + self.timeout = settings.rp_chat_timeout_sec + + def _client(self) -> httpx.AsyncClient: + return httpx.AsyncClient(timeout=self.timeout) + + async def health(self) -> dict[str, Any]: + async with self._client() as client: + response = await client.get(f"{self.base_url}/health") + return {"ok": response.status_code == 200, "status_code": response.status_code} + + async def sd_prompt( + self, + persona_id: str, + messages: list[dict[str, str]], + *, + appearance_override: str | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "persona_id": persona_id, + "messages": messages, + "outfit_json": "[]", + "use_prose": False, + } + if appearance_override: + payload["appearance_override"] = appearance_override + + async with self._client() as client: + response = await client.post(f"{self.base_url}/api/sd-prompt", json=payload) + if response.status_code >= 400: + return {"ok": False, "error": response.text[:500]} + data = response.json() + if data.get("skipped") or data.get("error"): + return {"ok": False, "error": data.get("error", "should_generate=false"), "raw": data} + return {"ok": True, **data} + + async def generate(self, positive: str, negative: str = "") -> dict[str, Any]: + async with self._client() as client: + response = await client.post( + f"{self.base_url}/api/generate", + json={"positive": positive, "negative": negative}, + ) + if response.status_code >= 400: + return {"ok": False, "error": response.text[:500]} + data = response.json() + if data.get("status") != "ok" or not data.get("image_path"): + return {"ok": False, "error": data.get("detail", "generation failed")} + return {"ok": True, **data} + + async def download_image(self, image_path: str) -> bytes | None: + path = image_path if image_path.startswith("/") else f"/{image_path}" + async with self._client() as client: + response = await client.get(f"{self.base_url}{path}") + if response.status_code != 200: + return None + return response.content + + async def save_image_locally(self, image_path: str) -> dict[str, Any]: + content = await self.download_image(image_path) + if not content: + return {"ok": False, "error": f"Не удалось скачать {image_path}"} + + settings = get_settings() + out_dir = Path(settings.generated_media_dir) + out_dir.mkdir(parents=True, exist_ok=True) + filename = f"{uuid.uuid4().hex}.png" + (out_dir / filename).write_bytes(content) + return { + "ok": True, + "filename": filename, + "url": f"/api/v1/media/generated/{filename}", + "source_path": image_path, + } diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index de30d2d..ada25bc 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -5,8 +5,8 @@ from sqlalchemy.orm import Session from app.fitness.service import FitnessService 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.image_gen import generate_image as run_generate_image from app.homelab.openmeteo import OpenMeteoClient from app.integrations.openfoodfacts import OpenFoodFactsClient from app.integrations.wger import WgerClient @@ -475,16 +475,24 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "function": { "name": "generate_image", "description": ( - "Сгенерировать картинку через ComfyUI на домашнем GPU. " - "Только по явному запросу или редко по рофлу." + "Аниме-картинка (Anima через RP-чат). " + "«Нарисуй себя» / портрет персонажа → draw_self=true. " + "Другая сцена → scene_description на английском (booru-теги). " + "Внешность берётся из карточки персонажа. Только по запросу или когда уместно." ), "parameters": { "type": "object", "properties": { - "prompt": {"type": "string", "description": "Описание картинки на английском"}, - "negative_prompt": {"type": "string"}, + "draw_self": { + "type": "boolean", + "description": "Нарисовать персонажа из карточки в контексте текущего чата", + }, + "scene_description": { + "type": "string", + "description": "Описание сцены на английском (booru-теги), если не draw_self", + }, }, - "required": ["prompt"], + "required": [], }, }, }, @@ -666,9 +674,11 @@ async def execute_tool( include_news=bool(include_news), ) elif name == "generate_image": - result = await ComfyUIClient().generate_image( - arguments.get("prompt", ""), - negative_prompt=arguments.get("negative_prompt"), + result = await run_generate_image( + db, + session_id=session_id, + draw_self=bool(arguments.get("draw_self")), + scene_description=arguments.get("scene_description", ""), ) else: return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) diff --git a/backend/prompts/assistant.md b/backend/prompts/assistant.md index 33fa099..2f1cb51 100644 --- a/backend/prompts/assistant.md +++ b/backend/prompts/assistant.md @@ -24,3 +24,8 @@ Погода и дайджест: - Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода] - Утренний брифинг — get_morning_briefing + +Картинки: +- «Нарисуй себя» → generate_image с draw_self=true +- Другая сцена → generate_image с scene_description на английском (booru-теги) +- Внешность персонажа задаётся в настройках карточки, не выдумывай теги diff --git a/frontend/src/pages/Character.css b/frontend/src/pages/Character.css index ea54255..e34ddb5 100644 --- a/frontend/src/pages/Character.css +++ b/frontend/src/pages/Character.css @@ -71,3 +71,28 @@ color: #8b95a5; font-size: 0.9rem; } + +.character-fieldset { + border: 1px solid #2f3748; + border-radius: 10px; + padding: 1rem; + margin: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.character-fieldset legend { + padding: 0 0.35rem; + color: #c5cdd8; +} + +.character-checkbox { + flex-direction: row !important; + align-items: center; + gap: 0.5rem !important; +} + +.character-checkbox input { + width: auto; +} diff --git a/frontend/src/pages/Character.tsx b/frontend/src/pages/Character.tsx index e42be1a..3f90f84 100644 --- a/frontend/src/pages/Character.tsx +++ b/frontend/src/pages/Character.tsx @@ -130,15 +130,6 @@ export default function Character() { /> -