added rp api
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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 по умолчанию).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,3 +24,8 @@
|
||||
Погода и дайджест:
|
||||
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
|
||||
- Утренний брифинг — get_morning_briefing
|
||||
|
||||
Картинки:
|
||||
- «Нарисуй себя» → generate_image с draw_self=true
|
||||
- Другая сцена → generate_image с scene_description на английском (booru-теги)
|
||||
- Внешность персонажа задаётся в настройках карточки, не выдумывай теги
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -130,15 +130,6 @@ export default function Character() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Первое сообщение (first_mes)
|
||||
<textarea
|
||||
rows={2}
|
||||
value={card.data.first_mes}
|
||||
onChange={(e) => updateField("first_mes", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Примеры диалога (mes_example)
|
||||
<textarea
|
||||
@@ -158,6 +149,68 @@ export default function Character() {
|
||||
/>
|
||||
</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>
|
||||
Теги (через запятую)
|
||||
<input
|
||||
|
||||
@@ -12,6 +12,12 @@ export interface CharacterCardData {
|
||||
creator_notes: string;
|
||||
alternate_greetings: 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 {
|
||||
@@ -38,21 +44,53 @@ export const DEFAULT_CARD: CharacterCardV2 = {
|
||||
creator_notes: "",
|
||||
alternate_greetings: [],
|
||||
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 {
|
||||
if (raw.data && typeof raw.data === "object") {
|
||||
const data = raw.data as Record<string, unknown>;
|
||||
return {
|
||||
spec: (raw.spec as string) ?? "chara_card_v2",
|
||||
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 {
|
||||
spec: "chara_card_v2",
|
||||
spec_version: "2.0",
|
||||
data: { ...DEFAULT_CARD.data, ...(raw as Partial<CharacterCardData>) },
|
||||
data: {
|
||||
...DEFAULT_CARD.data,
|
||||
...(flat as Partial<CharacterCardData>),
|
||||
...pickImageFields(flat),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user