added rp api

This commit is contained in:
2026-06-10 11:49:01 +03:00
parent 73baf4dbe1
commit f407e41b6d
13 changed files with 397 additions and 22 deletions
+5
View File
@@ -82,6 +82,11 @@ COMFYUI_ROFL_PROBABILITY=0.15
COMFYUI_ROFL_MIN_INTERVAL_HOURS=12 COMFYUI_ROFL_MIN_INTERVAL_HOURS=12
GENERATED_MEDIA_DIR=./data/generated 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 on server
NETDATA_BASE_URL=http://host.docker.internal:19999 NETDATA_BASE_URL=http://host.docker.internal:19999
NETDATA_PUBLIC_URL= NETDATA_PUBLIC_URL=
+2 -1
View File
@@ -215,7 +215,8 @@ lookup wger + Open Food Facts, напоминания в чат (`💪`), вкл
| Сервис | URL по умолчанию | Назначение | | Сервис | URL по умолчанию | Назначение |
|--------|------------------|------------| |--------|------------------|------------|
| Open-Meteo | `http://192.168.1.109:8085` | Погода СПб в контексте и tool `get_weather` | | 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 в чат | | Netdata | `http://host.docker.internal:19999` | Алерты warning/critical → notice в чат |
**Утренний дайджест** (`MORNING_DIGEST_HOUR=8`): погода + RSS (Habr, r/programming по умолчанию). **Утренний дайджест** (`MORNING_DIGEST_HOUR=8`): погода + RSS (Habr, r/programming по умолчанию).
+6
View File
@@ -22,6 +22,12 @@ class CharacterCardData(BaseModel):
creator_notes: str = "" creator_notes: str = ""
alternate_greetings: list[str] = Field(default_factory=list) alternate_greetings: list[str] = Field(default_factory=list)
character_version: str = "1.0" 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): class CharacterCardV2(BaseModel):
+3
View File
@@ -29,11 +29,14 @@ def homelab_status() -> dict:
"openmeteo": _probe(f"{settings.openmeteo_base_url.rstrip('/')}/v1/forecast?latitude=0&longitude=0&current=temperature_2m"), "openmeteo": _probe(f"{settings.openmeteo_base_url.rstrip('/')}/v1/forecast?latitude=0&longitude=0&current=temperature_2m"),
"comfyui": _probe(f"{settings.comfyui_base_url.rstrip('/')}/system_stats"), "comfyui": _probe(f"{settings.comfyui_base_url.rstrip('/')}/system_stats"),
"netdata": _probe(f"{settings.netdata_base_url.rstrip('/')}/api/v1/info"), "netdata": _probe(f"{settings.netdata_base_url.rstrip('/')}/api/v1/info"),
"rp_chat": _probe(f"{settings.rp_chat_base_url.rstrip('/')}/health"),
"config": { "config": {
"openmeteo_base_url": settings.openmeteo_base_url, "openmeteo_base_url": settings.openmeteo_base_url,
"comfyui_base_url": settings.comfyui_base_url, "comfyui_base_url": settings.comfyui_base_url,
"comfyui_backend": comfy_backend, "comfyui_backend": comfy_backend,
"comfyui_unet": settings.comfyui_unet, "comfyui_unet": settings.comfyui_unet,
"netdata_base_url": settings.netdata_base_url, "netdata_base_url": settings.netdata_base_url,
"rp_chat_base_url": settings.rp_chat_base_url,
"rp_chat_enabled": settings.rp_chat_enabled,
}, },
} }
+7 -1
View File
@@ -22,7 +22,7 @@ TOOLS_INSTRUCTIONS = """
- В текстовых ответах пользователю не используй эмодзи. - В текстовых ответах пользователю не используй эмодзи.
- Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай. - Погода: get_weather или блок [Погода] в контексте; «что на улице» / «будет ли дождь» — не выдумывай.
- Утренний брифинг (погода + новости) → get_morning_briefing. - Утренний брифинг (погода + новости) → get_morning_briefing.
- Картинки: generate_image (ComfyUI Anima) — промпт на английском, booru-теги + короткое описание; не злоупотребляй. - Картинки: generate_image — «нарисуй себя» → draw_self=true; иначе scene_description на английском (booru-теги). Внешность из карточки персонажа. Не злоупотребляй.
""".strip() """.strip()
DEFAULT_CARD: dict[str, Any] = { DEFAULT_CARD: dict[str, Any] = {
@@ -42,6 +42,12 @@ DEFAULT_CARD: dict[str, Any] = {
"creator": "", "creator": "",
"creator_notes": "", "creator_notes": "",
"character_version": "1.0", "character_version": "1.0",
"appearance_tags": "",
"appearance_prose": "",
"lora_name": "",
"lora_weight": 0.8,
"rp_persona_id": "",
"sd_enabled": True,
}, },
} }
+4
View File
@@ -88,6 +88,10 @@ class Settings(BaseSettings):
netdata_alerts_enabled: bool = True netdata_alerts_enabled: bool = True
netdata_poll_interval_sec: int = 120 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 @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()]
+130
View File
@@ -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
+89
View File
@@ -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,
}
+19 -9
View File
@@ -5,8 +5,8 @@ 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.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.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
@@ -475,16 +475,24 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"function": { "function": {
"name": "generate_image", "name": "generate_image",
"description": ( "description": (
"Сгенерировать картинку через ComfyUI на домашнем GPU. " "Аниме-картинка (Anima через RP-чат). "
"Только по явному запросу или редко по рофлу." "«Нарисуй себя» / портрет персонажа → draw_self=true. "
"Другая сцена → scene_description на английском (booru-теги). "
"Внешность берётся из карточки персонажа. Только по запросу или когда уместно."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"prompt": {"type": "string", "description": "Описание картинки на английском"}, "draw_self": {
"negative_prompt": {"type": "string"}, "type": "boolean",
"description": "Нарисовать персонажа из карточки в контексте текущего чата",
}, },
"required": ["prompt"], "scene_description": {
"type": "string",
"description": "Описание сцены на английском (booru-теги), если не draw_self",
},
},
"required": [],
}, },
}, },
}, },
@@ -666,9 +674,11 @@ async def execute_tool(
include_news=bool(include_news), include_news=bool(include_news),
) )
elif name == "generate_image": elif name == "generate_image":
result = await ComfyUIClient().generate_image( result = await run_generate_image(
arguments.get("prompt", ""), db,
negative_prompt=arguments.get("negative_prompt"), session_id=session_id,
draw_self=bool(arguments.get("draw_self")),
scene_description=arguments.get("scene_description", ""),
) )
else: else:
return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False) return json.dumps({"error": f"Unknown tool: {name}"}, ensure_ascii=False)
+5
View File
@@ -24,3 +24,8 @@
Погода и дайджест: Погода и дайджест:
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода] - Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
- Утренний брифинг — get_morning_briefing - Утренний брифинг — get_morning_briefing
Картинки:
- «Нарисуй себя» → generate_image с draw_self=true
- Другая сцена → generate_image с scene_description на английском (booru-теги)
- Внешность персонажа задаётся в настройках карточки, не выдумывай теги
+25
View File
@@ -71,3 +71,28 @@
color: #8b95a5; color: #8b95a5;
font-size: 0.9rem; 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;
}
+62 -9
View File
@@ -130,15 +130,6 @@ export default function Character() {
/> />
</label> </label>
<label>
Первое сообщение (first_mes)
<textarea
rows={2}
value={card.data.first_mes}
onChange={(e) => updateField("first_mes", e.target.value)}
/>
</label>
<label> <label>
Примеры диалога (mes_example) Примеры диалога (mes_example)
<textarea <textarea
@@ -158,6 +149,68 @@ export default function Character() {
/> />
</label> </label>
<fieldset className="character-fieldset">
<legend>Изображения (Anima / RP-чат)</legend>
<label className="character-checkbox">
<input
type="checkbox"
checked={card.data.sd_enabled}
onChange={(e) => updateField("sd_enabled", e.target.checked)}
/>
Разрешить генерацию картинок в чате
</label>
<label>
Appearance tags (booru, для «нарисуй себя»)
<textarea
rows={2}
value={card.data.appearance_tags}
onChange={(e) => updateField("appearance_tags", e.target.value)}
placeholder="silver_hair, wolf_ears, blue_eyes, ..."
/>
</label>
<label>
Appearance prose (контекст, не в промпт SD)
<textarea
rows={2}
value={card.data.appearance_prose}
onChange={(e) => updateField("appearance_prose", e.target.value)}
/>
</label>
<label>
LoRA (имя файла в ComfyUI)
<input
value={card.data.lora_name}
onChange={(e) => updateField("lora_name", e.target.value)}
placeholder="anima-preview-3-masterpieces-v5.safetensors"
/>
</label>
<label>
LoRA weight
<input
type="number"
min={0}
max={2}
step={0.05}
value={card.data.lora_weight}
onChange={(e) => updateField("lora_weight", Number(e.target.value))}
/>
</label>
<label>
RP persona_id (опционально)
<input
value={card.data.rp_persona_id}
onChange={(e) => updateField("rp_persona_id", e.target.value)}
placeholder="default или card_имя_карточки"
/>
</label>
</fieldset>
<label> <label>
Теги (через запятую) Теги (через запятую)
<input <input
+40 -2
View File
@@ -12,6 +12,12 @@ export interface CharacterCardData {
creator_notes: string; creator_notes: string;
alternate_greetings: string[]; alternate_greetings: string[];
character_version: string; character_version: string;
appearance_tags: string;
appearance_prose: string;
lora_name: string;
lora_weight: number;
rp_persona_id: string;
sd_enabled: boolean;
} }
export interface CharacterCardV2 { export interface CharacterCardV2 {
@@ -38,21 +44,53 @@ export const DEFAULT_CARD: CharacterCardV2 = {
creator_notes: "", creator_notes: "",
alternate_greetings: [], alternate_greetings: [],
character_version: "1.0", character_version: "1.0",
appearance_tags: "",
appearance_prose: "",
lora_name: "",
lora_weight: 0.8,
rp_persona_id: "",
sd_enabled: true,
}, },
}; };
function pickImageFields(data: Record<string, unknown>): Partial<CharacterCardData> {
const ext = data.extensions as Record<string, unknown> | undefined;
const out: Partial<CharacterCardData> = {};
const tags =
(data.appearance_tags as string) ||
(ext?.appearance_tags as string) ||
"";
if (tags) out.appearance_tags = tags;
const prose = (data.appearance_prose as string) || (ext?.appearance_prose as string) || "";
if (prose) out.appearance_prose = prose;
if (data.lora_name) out.lora_name = String(data.lora_name);
if (data.lora_weight != null) out.lora_weight = Number(data.lora_weight);
if (data.rp_persona_id) out.rp_persona_id = String(data.rp_persona_id);
return out;
}
export function normalizeCard(raw: CharacterCardV2 | Record<string, unknown>): CharacterCardV2 { export function normalizeCard(raw: CharacterCardV2 | Record<string, unknown>): CharacterCardV2 {
if (raw.data && typeof raw.data === "object") { if (raw.data && typeof raw.data === "object") {
const data = raw.data as Record<string, unknown>;
return { return {
spec: (raw.spec as string) ?? "chara_card_v2", spec: (raw.spec as string) ?? "chara_card_v2",
spec_version: (raw.spec_version as string) ?? "2.0", spec_version: (raw.spec_version as string) ?? "2.0",
data: { ...DEFAULT_CARD.data, ...(raw.data as Partial<CharacterCardData>) }, data: {
...DEFAULT_CARD.data,
...(data as Partial<CharacterCardData>),
...pickImageFields(data),
},
}; };
} }
const flat = raw as Record<string, unknown>;
return { return {
spec: "chara_card_v2", spec: "chara_card_v2",
spec_version: "2.0", spec_version: "2.0",
data: { ...DEFAULT_CARD.data, ...(raw as Partial<CharacterCardData>) }, data: {
...DEFAULT_CARD.data,
...(flat as Partial<CharacterCardData>),
...pickImageFields(flat),
},
}; };
} }