added rp api
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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 по умолчанию).
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"),
|
"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"),
|
"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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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.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": "Нарисовать персонажа из карточки в контексте текущего чата",
|
||||||
|
},
|
||||||
|
"scene_description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Описание сцены на английском (booru-теги), если не draw_self",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["prompt"],
|
"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)
|
||||||
|
|||||||
@@ -24,3 +24,8 @@
|
|||||||
Погода и дайджест:
|
Погода и дайджест:
|
||||||
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
|
- Вопросы о погоде, дожде, «что на улице» — используй get_weather или данные из блока [Погода]
|
||||||
- Утренний брифинг — get_morning_briefing
|
- Утренний брифинг — get_morning_briefing
|
||||||
|
|
||||||
|
Картинки:
|
||||||
|
- «Нарисуй себя» → generate_image с draw_self=true
|
||||||
|
- Другая сцена → generate_image с scene_description на английском (booru-теги)
|
||||||
|
- Внешность персонажа задаётся в настройках карточки, не выдумывай теги
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user