added rp api
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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"<lora:{lora_name}" in positive:
|
||||
return positive
|
||||
return f"{positive} <lora:{lora_name}:{lora_weight}>"
|
||||
|
||||
|
||||
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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user