Fixed SD Promt

This commit is contained in:
2026-06-02 15:03:39 +03:00
parent d4cd8f02f4
commit 03cbda5dce
46 changed files with 3285 additions and 429 deletions
+8
View File
@@ -86,6 +86,10 @@ async def _migrate_messages_columns(db):
await db.execute("ALTER TABLE messages ADD COLUMN image_prompt TEXT")
if "image_path" not in cols:
await db.execute("ALTER TABLE messages ADD COLUMN image_path TEXT")
if "image_prompt_alt" not in cols:
await db.execute("ALTER TABLE messages ADD COLUMN image_prompt_alt TEXT")
if "image_path_alt" not in cols:
await db.execute("ALTER TABLE messages ADD COLUMN image_path_alt TEXT")
async def _migrate_personas_columns(db):
@@ -105,6 +109,8 @@ async def _migrate_personas_columns(db):
await db.execute("ALTER TABLE personas ADD COLUMN avatar_path TEXT DEFAULT ''")
if "alternate_greetings_json" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN alternate_greetings_json TEXT DEFAULT '[]'")
if "appearance_prose" not in cols:
await db.execute("ALTER TABLE personas ADD COLUMN appearance_prose TEXT DEFAULT ''")
async def _migrate_sessions_columns(db):
@@ -170,3 +176,5 @@ async def _migrate_characters_columns(db):
await db.execute("ALTER TABLE characters ADD COLUMN avatar_path TEXT DEFAULT ''")
if "alternate_greetings_json" not in cols:
await db.execute("ALTER TABLE characters ADD COLUMN alternate_greetings_json TEXT DEFAULT '[]'")
if "appearance_prose" not in cols:
await db.execute("ALTER TABLE characters ADD COLUMN appearance_prose TEXT DEFAULT ''")
+9 -1
View File
@@ -3,9 +3,10 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from routers import chat, personas, sessions, characters, images, translate
from routers import chat, personas, sessions, characters, images, translate, debug
from database.db import init_db
from services.persona_seed import seed_default_personas
from services.system_message_migration import migrate_static_system_messages
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
@@ -14,6 +15,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(messag
async def lifespan(app: FastAPI):
await init_db()
await seed_default_personas()
await migrate_static_system_messages()
yield
@@ -25,6 +27,7 @@ app.include_router(sessions.router)
app.include_router(characters.router)
app.include_router(images.router)
app.include_router(translate.router)
app.include_router(debug.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -34,6 +37,11 @@ async def root():
return FileResponse("static/index.html")
@app.get("/debug")
async def debug_page():
return FileResponse("static/debug.html")
@app.get("/health")
async def health():
return {"status": "ok"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+5
View File
@@ -24,6 +24,11 @@ class RegenerateRequest(BaseModel):
class ForkSessionRequest(BaseModel):
until_message_id: int
class RebindPersonaRequest(BaseModel):
persona_id: str
clear_history: bool = False
class ChatResponse(BaseModel):
reply: str
session_id: str
+1
View File
@@ -22,6 +22,7 @@ class CardPatch(BaseModel):
first_mes: Optional[str] = None
mes_example: Optional[str] = None
appearance_tags: Optional[str] = None
appearance_prose: Optional[str] = None
lora_name: Optional[str] = None
lora_weight: Optional[float] = None
alternate_greetings_json: Optional[str] = None
+219 -187
View File
@@ -3,14 +3,12 @@ import logging
import os
import random
import aiosqlite
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from database.db import DB_PATH
from models.schemas import ChatRequest, ChatResponse, MessageEditRequest, RegenerateRequest
from services.llm import send_message, stream_message
from services.llm import LLMError, send_message, stream_message
from services.memory import (
get_history,
add_message,
@@ -18,7 +16,6 @@ from services.memory import (
get_or_create_session,
get_session,
update_session_title,
update_session_persona,
get_message_count,
get_last_assistant_message_id,
update_message_image,
@@ -26,7 +23,6 @@ from services.memory import (
update_session_status_quo,
update_session_affinity,
update_session_genre,
update_session_rpg_settings,
update_session_outfit,
update_session_plot_arc,
upsert_quest,
@@ -36,20 +32,23 @@ from services.memory import (
update_message_content,
delete_messages_after,
delete_message,
upsert_static_system_message,
)
from services.personas import get_persona
from services.chat_prompt import get_system_prompt, DEFAULT_PROMPT
from services.session_identity import resolve_session_persona
from services.sd_prompt import generate_sd_prompt, strip_image_prompt_tag, extract_image_prompt_tag
from services.lorebook import get_lorebook_context
from services.sd_images import run_sd_for_message
from services.character_card import get_character
from services import sdbackend as sd_service
from services.rpg_facts import extract_facts, merge_facts, facts_to_prompt
from services.rpg_plot import generate_plot_arc, should_advance_arc, pop_matching_beats, advance_phase
from services.rpg_narrator import narrator_pre, narrator_post
from services.opening import ensure_plot_arc_and_quests, resolve_greeting, process_opening
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/chat", tags=["chat"])
DEFAULT_PROMPT = "Ты — полезный AI ассистент. Отвечай чётко и по делу."
SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes")
DEFAULT_RPG_SETTINGS = {"dice": True, "narrator": True, "quests": True, "affinity": True, "choices": True}
@@ -72,24 +71,20 @@ def affinity_prompt_block(affinity: int) -> str:
return f"\n\n--- Relationship ---\nAffinity toward player: {affinity} ({tone}). Reflect this in your attitude and word choice.\n---"
async def get_system_prompt(persona_id: str, history: list, user_message: str = "") -> str:
persona = await get_persona(persona_id)
if not persona:
return DEFAULT_PROMPT
prompt = persona["prompt"]
recent = [m for m in history if m["role"] in ("user", "assistant")][-5:]
context = recent + [{"role": "user", "content": user_message}]
if persona.get("lorebook_json"):
lore = get_lorebook_context(persona.get("lorebook_json", "[]"), context)
if lore:
prompt += "\n\n" + lore
if persona_id.startswith("card_"):
card = await get_character(persona_id[5:])
if card:
lore = get_lorebook_context(card.get("lorebook_json", "[]"), context)
if lore:
prompt += "\n\n" + lore
return prompt
def messages_for_llm(history: list, llm_system_content: str) -> list[dict]:
"""Build LLM payload: one system message (static + runtime), no duplicate system rows."""
out: list[dict] = []
system_used = False
for m in history:
if m["role"] == "system":
if not system_used:
out.append({"role": "system", "content": llm_system_content})
system_used = True
else:
out.append({"role": m["role"], "content": m["content"]})
if not system_used:
out.insert(0, {"role": "system", "content": llm_system_content})
return out
@router.get("/history/{session_id}")
@@ -100,11 +95,18 @@ async def get_chat_history(session_id: str):
@router.get("/system/{session_id}")
async def get_system_blob(session_id: str):
history = await get_history(session_id)
system_msg = next((m for m in history if m.get("role") == "system"), None)
session = await get_session(session_id)
persona_id = (session.get("persona_id") if session else None) or "default"
persona = await get_persona(persona_id) or {}
system_msg = next((m for m in history if m.get("role") == "system"), None)
stored = system_msg.get("content") if system_msg else ""
live_static = await get_system_prompt(persona_id, history, "")
system_prompt = live_static if live_static else stored
quests = await get_quests(session_id)
return {
"system_prompt": system_msg.get("content") if system_msg else "",
"persona_id": persona_id,
"persona_name": persona.get("name", persona_id),
"system_prompt": system_prompt,
"status_quo": session.get("status_quo") if session else "",
"facts_json": session.get("facts_json") if session else "[]",
"plot_arc_json": session.get("plot_arc_json") if session else "{}",
@@ -119,14 +121,21 @@ async def get_system_blob(session_id: str):
@router.post("/init")
async def init_chat(request: ChatRequest):
persona_id = request.persona_id or "default"
await get_or_create_session(request.session_id, persona_id)
await get_or_create_session(
request.session_id,
request.persona_id or "default",
)
persona_id = await resolve_session_persona(
request.session_id,
request.persona_id,
create_persona=request.persona_id,
)
history = await get_history(request.session_id)
if history:
return {"first_mes": None}
system_prompt = await get_system_prompt(persona_id, [], "")
await add_message(request.session_id, "system", system_prompt)
await upsert_static_system_message(request.session_id, system_prompt, [])
first_mes = None
if request.first_mes_override and request.first_mes_override.strip():
@@ -152,53 +161,47 @@ class RpgBootstrapRequest(BaseModel):
genre: str = "adventure"
class OpeningProcessRequest(BaseModel):
session_id: str
persona_id: str = "default"
rpg: bool = False
@router.post("/opening/process")
async def opening_process(req: OpeningProcessRequest):
await get_or_create_session(req.session_id, req.persona_id)
persona_id = await resolve_session_persona(req.session_id, req.persona_id)
try:
return await process_opening(req.session_id, persona_id, rpg=req.rpg)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/rpg/bootstrap")
async def rpg_bootstrap(req: RpgBootstrapRequest):
await get_or_create_session(req.session_id, req.persona_id)
session = await get_session(req.session_id)
persona = await get_persona(req.persona_id) or {}
# Save genre
persona_id = await resolve_session_persona(req.session_id, req.persona_id)
await update_session_genre(req.session_id, req.genre)
arc_json = (session.get("plot_arc_json") or "{}") if session else "{}"
try:
arc = json.loads(arc_json) if isinstance(arc_json, str) else {}
except Exception:
arc = {}
if not arc:
facts_block = facts_to_prompt((session or {}).get("facts_json", "[]"))
arc = await generate_plot_arc(
persona.get("name", req.persona_id),
persona.get("description", ""),
persona.get("scenario", ""),
persona.get("first_mes", ""),
facts_block=facts_block,
genre=req.genre,
)
if arc:
from services.memory import update_session_plot_arc
await update_session_plot_arc(req.session_id, json.dumps(arc, ensure_ascii=False))
# Seed quests from beats
for beat in arc.get("beats", []):
title = (beat.get("title") or beat.get("injection", "")).strip()
if title:
await upsert_quest(req.session_id, title[:120])
persona = await get_persona(persona_id) or {}
greeting = await resolve_greeting(req.session_id, persona)
arc = await ensure_plot_arc_and_quests(req.session_id, persona, greeting, req.genre)
quests = await get_quests(req.session_id)
return {"plot_arc": arc, "quests": quests}
@router.post("/stream")
async def chat_stream(request: ChatRequest):
persona_id = request.persona_id or "default"
await get_or_create_session(request.session_id, persona_id)
await get_or_create_session(request.session_id, request.persona_id)
persona_id = await resolve_session_persona(
request.session_id,
request.persona_id,
create_persona=request.persona_id,
)
history = await get_history(request.session_id)
session = await get_session(request.session_id)
system_prompt = await get_system_prompt(persona_id, history, request.message)
static_prompt = await get_system_prompt(persona_id, history, request.message)
runtime_suffix = ""
arc = {}
roll = None
@@ -206,26 +209,27 @@ async def chat_stream(request: ChatRequest):
resolution_text = ""
narrator_msg = None # shown as narrator bubble before assistant reply
rpg_settings = {}
facts_block = ""
if session and session.get("rpg_enabled"):
rpg_settings = get_rpg_settings(session)
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
if facts_block:
system_prompt = system_prompt + "\n\n" + facts_block
runtime_suffix += "\n\n" + facts_block
try:
arc = json.loads(session.get("plot_arc_json") or "{}")
except Exception:
arc = {}
if arc:
system_prompt = system_prompt + "\n\n--- PlotArc ---\n" + json.dumps(
runtime_suffix += "\n\n--- PlotArc ---\n" + json.dumps(
{k: arc.get(k) for k in ("title", "phase", "next_beat_hint")}, ensure_ascii=False
) + "\n---"
status_quo = (session.get("status_quo") or "").strip()
if status_quo:
system_prompt = system_prompt + "\n\n--- Status quo ---\n" + status_quo + "\n---"
runtime_suffix += "\n\n--- Status quo ---\n" + status_quo + "\n---"
if rpg_settings.get("affinity", True):
aff = int(session.get("affinity") or 0)
system_prompt = system_prompt + affinity_prompt_block(aff)
runtime_suffix += affinity_prompt_block(aff)
if rpg_settings.get("narrator", True):
persona = await get_persona(persona_id) or {}
@@ -274,7 +278,7 @@ async def chat_stream(request: ChatRequest):
pre_sq = (pre.get("status_quo_update") or "").strip()
if directives:
system_prompt = system_prompt + "\n\n--- Narrator directives ---\n" + "\n".join(f"- {d}" for d in directives) + "\n---"
runtime_suffix += "\n\n--- Narrator directives ---\n" + "\n".join(f"- {d}" for d in directives) + "\n---"
if pre_sq:
await update_session_status_quo(request.session_id, pre_sq)
session["status_quo"] = pre_sq
@@ -290,50 +294,37 @@ async def chat_stream(request: ChatRequest):
)
narrator_msg = {"roll": roll, "outcome": outcome, "text": resolution_text}
# Inject outcome into system prompt so character reply is consistent
if roll is not None:
system_prompt = (
system_prompt
+ f"\n\n--- Mechanics ---\n"
+ f"Roll d20={roll}. Outcome: {outcome}.\n"
runtime_suffix += (
f"\n\n--- Mechanics ---\n"
f"Roll d20={roll}. Outcome: {outcome}.\n"
+ "Your reply MUST be consistent with this outcome. Do NOT contradict the narrator resolution.\n"
+ "---"
)
# is_narrator_choice: wrap message so LLM understands context
llm_system = static_prompt + runtime_suffix
user_message_content = request.message
if request.is_narrator_choice:
user_message_content = f"[Player chose: {request.message}]"
if not history:
await add_message(request.session_id, "system", system_prompt)
elif history[0]["role"] == "system" and history[0]["content"] != system_prompt:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""UPDATE messages SET content = ?
WHERE session_id = ? AND role = 'system'
AND id = (SELECT MIN(id) FROM messages WHERE session_id = ?)""",
(system_prompt, request.session_id, request.session_id),
)
await db.commit()
await upsert_static_system_message(request.session_id, static_prompt, history)
if not request.skip_user_add:
await add_message(request.session_id, "user", user_message_content)
messages = await get_history(request.session_id)
llm_messages = messages_for_llm(messages, llm_system)
full_reply = []
async def generate():
nonlocal arc
# Send narrator BEFORE streaming so it appears above the reply
if narrator_msg:
yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n"
try:
async for chunk in stream_message(
[{"role": m["role"], "content": m["content"]} for m in messages]
):
async for chunk in stream_message(llm_messages):
full_reply.append(chunk)
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
except Exception as e:
@@ -344,97 +335,111 @@ async def chat_stream(request: ChatRequest):
complete = "".join(full_reply)
display_text = strip_image_prompt_tag(complete)
hist_with_reply = await get_history(request.session_id) + [
{"role": "assistant", "content": display_text}
]
sd_result = await generate_sd_prompt(
hist_with_reply, persona_id,
outfit_json=session.get("outfit_json", "[]") if session else "[]"
)
prompt_str = (sd_result[0] if sd_result and sd_result[0] else None) or extract_image_prompt_tag(complete)
if (display_text or complete).strip():
await add_message(request.session_id, "assistant", display_text or complete, image_prompt=prompt_str)
await add_message(request.session_id, "assistant", display_text or complete)
choices = []
debug_blocks = []
quests_updated = []
if session and session.get("rpg_enabled"):
if not arc:
try:
if not arc:
persona = await get_persona(persona_id) or {}
arc = await generate_plot_arc(
persona.get("name", persona_id),
persona.get("description", ""),
persona.get("scenario", ""),
persona.get("first_mes", ""),
facts_block=facts_to_prompt(session.get("facts_json", "[]")),
genre=session.get("genre") or "adventure",
)
if arc:
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
debug_blocks.append({
"type": "plot_arc",
"text": json.dumps(arc, ensure_ascii=False, indent=2),
})
if rpg_settings.get("quests", True):
for beat in arc.get("beats", []):
t = (beat.get("title") or beat.get("injection", "")).strip()
if t:
await upsert_quest(request.session_id, t[:120])
trig = should_advance_arc(request.message)
if trig and arc:
arc, beats = pop_matching_beats(arc, trig, max_beats=1)
if beats:
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
inj = beats[0].get("injection", "")
if inj:
debug_blocks.append({"type": "narrator_injection", "text": inj})
if rpg_settings.get("choices", True):
choices += beats[0].get("choices") or []
if advance_phase(arc):
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
debug_blocks.append({"type": "phase_advance", "text": arc["phase"]})
ctx = [
m for m in (await get_history(request.session_id))
if m["role"] in ("user", "assistant")
][-10:]
new_facts = await extract_facts(ctx)
if new_facts:
merged = merge_facts(session.get("facts_json", "[]"), new_facts)
await update_session_facts(request.session_id, merged)
session["facts_json"] = merged
persona = await get_persona(persona_id) or {}
arc = await generate_plot_arc(
persona.get("name", persona_id),
persona.get("description", ""),
persona.get("scenario", ""),
persona.get("first_mes", ""),
facts_block=facts_to_prompt(session.get("facts_json", "[]")),
genre=session.get("genre") or "adventure",
ctx_txt = "\n".join(
f"{m['role']}: {m['content']}"
for m in ctx[-8:]
if m.get("role") in ("user", "assistant")
)
post = await narrator_post(
persona.get("name", persona_id),
ctx_txt,
json.dumps(arc, ensure_ascii=False) if arc else "",
facts_to_prompt(session.get("facts_json", "[]")),
)
if arc:
await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False))
debug_blocks.append({"type": "plot_arc", "text": json.dumps(arc, ensure_ascii=False, indent=2)})
if rpg_settings.get("quests", True):
for beat in arc.get("beats", []):
t = (beat.get("title") or beat.get("injection", "")).strip()
if t:
await upsert_quest(request.session_id, t[:120])
trig = should_advance_arc(request.message)
if trig and arc:
arc, beats = pop_matching_beats(arc, trig, max_beats=1)
if beats:
await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False))
inj = beats[0].get("injection", "")
if inj:
debug_blocks.append({"type": "narrator_injection", "text": inj})
if rpg_settings.get("choices", True):
choices += beats[0].get("choices") or []
if advance_phase(arc):
await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False))
debug_blocks.append({"type": "phase_advance", "text": arc["phase"]})
sq = (post.get("status_quo_update") or "").strip()
if sq:
await update_session_status_quo(request.session_id, sq)
debug_blocks.append({"type": "status_quo", "text": sq})
ctx = [m for m in (await get_history(request.session_id)) if m["role"] in ("user", "assistant")][-10:]
new_facts = await extract_facts(ctx)
if new_facts:
merged = merge_facts(session.get("facts_json", "[]"), new_facts)
await update_session_facts(request.session_id, merged)
session["facts_json"] = merged
if rpg_settings.get("choices", True):
choices += post.get("choices") or []
persona = await get_persona(persona_id) or {}
ctx_txt = "\n".join(f"{m['role']}: {m['content']}" for m in ctx[-8:] if m.get("role") in ("user", "assistant"))
post = await narrator_post(
persona.get("name", persona_id),
ctx_txt,
json.dumps(arc, ensure_ascii=False) if arc else "",
facts_to_prompt(session.get("facts_json", "[]")),
)
if rpg_settings.get("affinity", True):
delta = int(post.get("affinity_delta") or 0)
if delta:
await update_session_affinity(request.session_id, delta)
sq = (post.get("status_quo_update") or "").strip()
if sq:
await update_session_status_quo(request.session_id, sq)
debug_blocks.append({"type": "status_quo", "text": sq})
outfit_update = post.get("outfit_update")
if isinstance(outfit_update, list) and outfit_update:
outfit_str = json.dumps(outfit_update, ensure_ascii=False)
await update_session_outfit(request.session_id, outfit_str)
session["outfit_json"] = outfit_str
if rpg_settings.get("choices", True):
choices += post.get("choices") or []
if rpg_settings.get("affinity", True):
delta = int(post.get("affinity_delta") or 0)
if delta:
await update_session_affinity(request.session_id, delta)
outfit_update = post.get("outfit_update")
if isinstance(outfit_update, list) and outfit_update:
outfit_str = json.dumps(outfit_update, ensure_ascii=False)
await update_session_outfit(request.session_id, outfit_str)
session["outfit_json"] = outfit_str
if rpg_settings.get("quests", True):
for qu in (post.get("quest_updates") or []):
t = (qu.get("title") or "").strip()
if t:
await upsert_quest(request.session_id, t[:120], qu.get("status", "active"))
quests_updated = await get_quests(request.session_id)
if rpg_settings.get("quests", True):
for qu in (post.get("quest_updates") or []):
t = (qu.get("title") or "").strip()
if t:
await upsert_quest(
request.session_id, t[:120], qu.get("status", "active")
)
quests_updated = await get_quests(request.session_id)
except LLMError as e:
logger.warning("RPG post-process skipped after reply: %s", e)
except Exception as e:
logger.exception("RPG post-process failed after reply: %s", e)
count = await get_message_count(request.session_id)
if count == 2 and not request.skip_user_add:
@@ -443,23 +448,50 @@ async def chat_stream(request: ChatRequest):
if (session or {}).get("title", "Новый чат") in ("", "Новый чат"):
await update_session_title(request.session_id, f"{persona.get('name', persona_id)}{preview}")
image_path = None
image_error = None
if prompt_str and SD_AUTO_GENERATE:
updated_session = await get_session(request.session_id) or session
hist = await get_history(request.session_id)
bundle = await generate_sd_prompt(
hist,
persona_id,
outfit_json=updated_session.get("outfit_json", "[]") if updated_session else "[]",
)
prompt_str = bundle.tag_full if bundle else extract_image_prompt_tag(complete)
msg_id = await get_last_assistant_message_id(request.session_id)
sd_out: dict = {}
if bundle:
yield f"data: {json.dumps({
'image_generating': True,
'image_prompt': bundle.tag_full,
'image_prompt_alt': bundle.desc_full,
})}\n\n"
sd_out = await run_sd_for_message(bundle, msg_id)
elif prompt_str and SD_AUTO_GENERATE:
yield f"data: {json.dumps({'image_generating': True, 'image_prompt': prompt_str})}\n\n"
rel, err = await sd_service.generate_from_full_prompt(prompt_str)
if rel:
image_path = rel
msg_id = await get_last_assistant_message_id(request.session_id)
sd_out["image_path"] = f"/static/{rel}"
if msg_id:
await update_message_image(msg_id, rel)
else:
image_error = err
sd_out["image_error"] = err
sd_out["image_prompt"] = prompt_str
updated_session = await get_session(request.session_id)
affinity = updated_session.get("affinity", 0) if updated_session else 0
yield f"data: {json.dumps({'done': True, 'image_prompt': prompt_str, 'image_path': f'/static/{image_path}' if image_path else None, 'image_error': image_error, 'choices': choices, 'debug': debug_blocks, 'affinity': affinity, 'quests': quests_updated})}\n\n"
yield f"data: {json.dumps({
'done': True,
'image_prompt': sd_out.get('image_prompt') or prompt_str,
'image_prompt_alt': sd_out.get('image_prompt_alt'),
'image_path': sd_out.get('image_path'),
'image_path_alt': sd_out.get('image_path_alt'),
'image_error': sd_out.get('image_error'),
'image_error_alt': sd_out.get('image_error_alt'),
'choices': choices,
'debug': debug_blocks,
'affinity': affinity,
'quests': quests_updated,
})}\n\n"
return StreamingResponse(
generate(),
@@ -470,23 +502,24 @@ async def chat_stream(request: ChatRequest):
@router.post("/", response_model=ChatResponse)
async def chat(request: ChatRequest):
persona_id = request.persona_id or "default"
await get_or_create_session(request.session_id, persona_id)
await get_or_create_session(request.session_id, request.persona_id)
persona_id = await resolve_session_persona(
request.session_id,
request.persona_id,
create_persona=request.persona_id,
)
history = await get_history(request.session_id)
system_prompt = await get_system_prompt(persona_id, history, request.message)
if not history:
await add_message(request.session_id, "system", system_prompt)
static_prompt = await get_system_prompt(persona_id, history, request.message)
await upsert_static_system_message(request.session_id, static_prompt, history)
await add_message(request.session_id, "user", request.message)
messages = await get_history(request.session_id)
reply = await send_message(
[{"role": m["role"], "content": m["content"]} for m in messages]
)
llm_messages = messages_for_llm(messages, static_prompt)
reply = await send_message(llm_messages)
display = strip_image_prompt_tag(reply)
prompt_tuple = await generate_sd_prompt(messages, persona_id)
prompt_str = prompt_tuple[0] if prompt_tuple else extract_image_prompt_tag(reply)
bundle = await generate_sd_prompt(messages, persona_id)
prompt_str = bundle.tag_full if bundle else extract_image_prompt_tag(reply)
await add_message(request.session_id, "assistant", display, image_prompt=prompt_str)
@@ -527,7 +560,6 @@ async def regenerate_chat(req: RegenerateRequest):
stream_req = ChatRequest(
message=user_text,
session_id=req.session_id,
persona_id=req.persona_id,
skip_user_add=True,
)
return await chat_stream(stream_req)
+248
View File
@@ -0,0 +1,248 @@
import json
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import sdbackend as sd_service
from services.comfy_models import list_node_types, parse_model_lists
from services.llm import (
CHAT_MODEL,
LLM_FALLBACK_MODEL,
LLMError,
SYSTEM_MODEL,
send_message,
send_message_with_model,
)
from services.personas import get_all_personas
from services.sd_prompt import (
SD_PROMPT_MODEL,
anima_dual_enabled,
run_prompt_builder,
)
router = APIRouter(prefix="/debug", tags=["debug"])
class ChatMessage(BaseModel):
role: str
content: str
class SdPromptDebugRequest(BaseModel):
persona_id: str = "default"
chat_excerpt: str = ""
messages: list[ChatMessage] | None = None
outfit_json: str = "[]"
appearance_override: str | None = None
use_prose: bool = False
class LlmDebugRequest(BaseModel):
model: str = ""
system: str = ""
user: str = ""
messages: list[ChatMessage] | None = None
class ComfyRawRequest(BaseModel):
method: str = "GET"
path: str = "/system_stats"
params_json: str = "{}"
body_json: str = ""
class ComfyGenerateRequest(BaseModel):
positive: str
negative: str = ""
unet: str | None = None
clip: str | None = None
vae: str | None = None
checkpoint: str | None = None
@router.get("/config")
async def debug_config():
base = sd_service.SD_BASE_URL
return {
"chat_model": CHAT_MODEL,
"system_model": SYSTEM_MODEL,
"llm_fallback_model": LLM_FALLBACK_MODEL,
"sd_prompt_model": SD_PROMPT_MODEL or SYSTEM_MODEL,
"sd_base_url": base,
"sd_has_token": bool(sd_service.SD_QUERY_PARAMS.get("token")),
"sd_anima_dual": anima_dual_enabled(),
"sd_unet": sd_service.SD_UNET,
"sd_clip": sd_service.SD_CLIP,
"sd_vae": sd_service.SD_VAE,
"sd_checkpoint": sd_service.SD_CHECKPOINT,
"sd_steps": sd_service.SD_STEPS,
"sd_cfg": sd_service.SD_CFG,
"router_key_set": bool(os.getenv("ROUTER_KEY")),
}
@router.get("/personas")
async def debug_personas():
personas = await get_all_personas()
return [
{
"persona_id": pid,
"name": p.get("name", pid),
"appearance_tags": p.get("appearance_tags", ""),
}
for pid, p in personas.items()
]
@router.post("/sd-prompt")
async def debug_sd_prompt(req: SdPromptDebugRequest):
msgs = None
if req.messages:
msgs = [m.model_dump() for m in req.messages]
return await run_prompt_builder(
req.persona_id,
messages=msgs,
chat_excerpt=req.chat_excerpt,
outfit_json=req.outfit_json,
appearance_override=req.appearance_override,
use_prose=req.use_prose,
)
@router.post("/llm")
async def debug_llm(req: LlmDebugRequest):
if req.messages:
messages = [m.model_dump() for m in req.messages]
else:
messages = []
if req.system.strip():
messages.append({"role": "system", "content": req.system.strip()})
if req.user.strip():
messages.append({"role": "user", "content": req.user.strip()})
if not messages:
raise HTTPException(status_code=400, detail="Нужны messages или system/user")
model = (req.model or "").strip() or SD_PROMPT_MODEL or SYSTEM_MODEL
try:
if model in (SYSTEM_MODEL, "") and not req.model:
text = await send_message(messages)
else:
text = await send_message_with_model(messages, model)
return {"model": model, "response": text}
except LLMError as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/comfy/ping")
async def debug_comfy_ping():
try:
status, body, headers = await sd_service.comfy_api_request("GET", "/system_stats")
return {"ok": status == 200, "status": status, "body": body, "headers": headers}
except Exception as e:
return {"ok": False, "error": str(e)}
@router.get("/comfy/models")
async def debug_comfy_models():
try:
info = await sd_service.fetch_object_info()
return {
"models": parse_model_lists(info),
"configured": {
"unet": sd_service.SD_UNET,
"clip": sd_service.SD_CLIP,
"vae": sd_service.SD_VAE,
"checkpoint": sd_service.SD_CHECKPOINT,
},
"node_type_count": len(list_node_types(info)),
}
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@router.get("/comfy/object_info")
async def debug_comfy_object_info(node: str | None = None):
try:
info = await sd_service.fetch_object_info()
if node:
if node not in info:
raise HTTPException(status_code=404, detail=f"Unknown node: {node}")
return {node: info[node]}
return {
"node_types": list_node_types(info),
"models": parse_model_lists(info),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/comfy/raw")
async def debug_comfy_raw(req: ComfyRawRequest):
path = req.path.strip()
if not path.startswith("/"):
path = "/" + path
try:
params = json.loads(req.params_json or "{}")
if not isinstance(params, dict):
raise ValueError("params_json must be object")
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"params_json: {e}")
body = None
if req.body_json.strip():
try:
body = json.loads(req.body_json)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"body_json: {e}")
method = req.method.upper()
if method not in ("GET", "POST", "PUT", "DELETE"):
raise HTTPException(status_code=400, detail="method must be GET|POST|PUT|DELETE")
try:
status, resp_body, headers = await sd_service.comfy_api_request(
method,
path,
params=params or None,
json_body=body,
timeout=120,
)
return {"status": status, "headers": headers, "body": resp_body}
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
@router.post("/comfy/generate")
async def debug_comfy_generate(req: ComfyGenerateRequest):
if not req.positive.strip():
raise HTTPException(status_code=400, detail="positive required")
overrides: dict[str, str] = {}
if req.unet:
overrides["unet"] = req.unet
if req.clip:
overrides["clip"] = req.clip
if req.vae:
overrides["vae"] = req.vae
if req.checkpoint:
overrides["checkpoint"] = req.checkpoint
full = req.positive.strip()
if req.negative.strip():
full += f"\n\nNegative prompt: {req.negative.strip()}"
try:
rel, err = await sd_service.generate_from_full_prompt(
full,
overrides=overrides or None,
)
if not rel:
raise HTTPException(status_code=502, detail=err or "generation failed")
return {"image_path": f"/static/{rel}", "status": "ok"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
+1
View File
@@ -57,6 +57,7 @@ class PersonaPatch(BaseModel):
lora_name: Optional[str] = None
lora_weight: Optional[float] = None
appearance_tags: Optional[str] = None
appearance_prose: Optional[str] = None
personality: Optional[str] = None
scenario: Optional[str] = None
first_mes: Optional[str] = None
+39 -2
View File
@@ -3,6 +3,7 @@ from services.memory import (
get_all_sessions,
get_session,
get_or_create_session,
get_history,
delete_session,
update_session_title,
update_session_persona,
@@ -17,7 +18,10 @@ from services.memory import (
get_last_message_preview,
fork_session,
)
from models.schemas import ForkSessionRequest
from models.schemas import ForkSessionRequest, RebindPersonaRequest
from services.chat_prompt import get_system_prompt
from services.memory import rebind_session_persona
from services.personas import get_persona
router = APIRouter(prefix="/sessions", tags=["sessions"])
@@ -46,9 +50,42 @@ async def get_session_route(session_id: str):
return s
@router.post("/{session_id}/rebind-persona")
async def rebind_persona(session_id: str, body: RebindPersonaRequest):
session = await get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Сессия не найдена")
persona = await get_persona(body.persona_id)
if not persona:
raise HTTPException(status_code=400, detail="Персонаж не найден")
hist = [] if body.clear_history else await get_history(session_id)
static = await get_system_prompt(body.persona_id, hist, "")
first_mes = (persona.get("first_mes") or "").strip() if body.clear_history else None
try:
await rebind_session_persona(
session_id,
body.persona_id,
clear_history=body.clear_history,
static_prompt=static,
first_mes=first_mes or None,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {
"persona_id": body.persona_id,
"persona_name": persona.get("name", body.persona_id),
"system_prompt_preview": static[:500],
"clear_history": body.clear_history,
}
@router.patch("/{session_id}")
async def patch_session(session_id: str, data: dict):
await get_or_create_session(session_id, data.get("persona_id", "default"))
create_pid = data.get("persona_id") if "persona_id" in data else None
await get_or_create_session(session_id, create_pid)
if "title" in data:
await update_session_title(session_id, data["title"])
if "persona_id" in data:
+14 -11
View File
@@ -45,6 +45,7 @@ def parse_card_v2(data: dict, card_id: str | None = None) -> dict:
"first_mes": inner.get("first_mes", ""),
"mes_example": inner.get("mes_example", ""),
"appearance_tags": _extract_appearance(inner),
"appearance_prose": "",
"lorebook_json": json.dumps(entries, ensure_ascii=False),
"alternate_greetings": alternates,
"alternate_greetings_json": json.dumps(alternates, ensure_ascii=False),
@@ -141,13 +142,13 @@ async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""INSERT OR REPLACE INTO characters
(card_id, name, description, personality, scenario, first_mes,
mes_example, raw_json, lora_name, lora_weight, appearance_tags, lorebook_json,
avatar_path, alternate_greetings_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO characters
(card_id, name, description, personality, scenario, first_mes, mes_example,
raw_json, lora_name, lora_weight, appearance_tags, appearance_prose, lorebook_json, avatar_path,
alternate_greetings_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
card_id,
card["card_id"],
card["name"],
card["description"],
card["personality"],
@@ -157,10 +158,11 @@ async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0
card["raw_json"],
lora_name,
lora_weight,
card.get("appearance_tags", ""),
card["appearance_tags"],
card.get("appearance_prose", ""),
card["lorebook_json"],
card.get("avatar_path", ""),
alt_json,
card.get("alternate_greetings_json", "[]"),
),
)
await db.commit()
@@ -199,8 +201,8 @@ async def delete_character(card_id: str) -> bool:
async def update_appearance_tags(card_id: str, appearance_tags: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE characters SET appearance_tags = ? WHERE card_id = ?",
(appearance_tags, card_id),
"UPDATE characters SET appearance_tags = ?, appearance_prose = ? WHERE card_id = ?",
(appearance_tags, "", card_id),
)
await db.commit()
@@ -228,7 +230,7 @@ async def preview_card_file(content: bytes, filename: str) -> dict:
async def update_character(card_id: str, fields: dict) -> bool:
allowed = {"name", "description", "personality", "scenario", "first_mes",
"mes_example", "appearance_tags", "lora_name", "lora_weight", "avatar_path",
"mes_example", "appearance_tags", "appearance_prose", "lora_name", "lora_weight", "avatar_path",
"alternate_greetings_json"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
@@ -295,6 +297,7 @@ async def import_card_file(
"lora_name": lora_name,
"lora_weight": lora_weight,
"appearance_tags": saved.get("appearance_tags", ""),
"appearance_prose": saved.get("appearance_prose", ""),
"avatar_path": saved.get("avatar_path", ""),
"personality": saved.get("personality", ""),
"scenario": saved.get("scenario", ""),
+26
View File
@@ -0,0 +1,26 @@
from services.personas import get_persona
from services.lorebook import get_lorebook_context
from services.character_card import get_character
DEFAULT_PROMPT = "Ты — полезный AI ассистент. Отвечай чётко и по делу."
async def get_system_prompt(persona_id: str, history: list, user_message: str = "") -> str:
"""Static character prompt only (no RPG runtime blocks)."""
persona = await get_persona(persona_id)
if not persona:
return DEFAULT_PROMPT
prompt = persona["prompt"]
recent = [m for m in history if m["role"] in ("user", "assistant")][-5:]
context = recent + [{"role": "user", "content": user_message}]
if persona.get("lorebook_json"):
lore = get_lorebook_context(persona.get("lorebook_json", "[]"), context)
if lore:
prompt += "\n\n" + lore
if persona_id.startswith("card_"):
card = await get_character(persona_id[5:])
if card:
lore = get_lorebook_context(card.get("lorebook_json", "[]"), context)
if lore:
prompt += "\n\n" + lore
return prompt
+40
View File
@@ -0,0 +1,40 @@
"""Parse ComfyUI /object_info into usable model lists."""
from __future__ import annotations
# Node types whose combo inputs we expose in the debug UI
_MODEL_NODES: dict[str, tuple[str, str]] = {
"checkpoints": ("CheckpointLoaderSimple", "ckpt_name"),
"unets": ("UNETLoader", "unet_name"),
"clips": ("CLIPLoader", "clip_name"),
"vaes": ("VAELoader", "vae_name"),
"loras": ("LoraLoader", "lora_name"),
}
def _combo_options(node_def: dict, input_name: str) -> list[str]:
if not isinstance(node_def, dict):
return []
required = (node_def.get("input") or {}).get("required") or {}
optional = (node_def.get("input") or {}).get("optional") or {}
spec = required.get(input_name) or optional.get(input_name)
if not spec or not isinstance(spec, (list, tuple)):
return []
first = spec[0]
if isinstance(first, list):
return [str(x) for x in first]
return []
def parse_model_lists(object_info: dict) -> dict[str, list[str]]:
out: dict[str, list[str]] = {}
for key, (node_type, input_name) in _MODEL_NODES.items():
node_def = object_info.get(node_type) or {}
options = _combo_options(node_def, input_name)
if options:
out[key] = options
return out
def list_node_types(object_info: dict) -> list[str]:
return sorted(k for k in object_info.keys() if isinstance(object_info.get(k), dict))
+119 -6
View File
@@ -13,6 +13,8 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
CHAT_MODEL = os.getenv("CHAT_MODEL", "mistralai/mistral-nemo")
SYSTEM_MODEL = os.getenv("SYSTEM_MODEL", "google/gemini-2.5-flash")
# Softer model when primary returns content_filter / empty / API errors (default: CHAT_MODEL).
LLM_FALLBACK_MODEL = (os.getenv("LLM_FALLBACK_MODEL") or "").strip() or CHAT_MODEL
HEADERS = {
"Authorization": f"Bearer {OPENROUTER_KEY}",
@@ -21,26 +23,128 @@ HEADERS = {
}
class LLMError(Exception):
"""OpenRouter returned an error or an unexpected response shape."""
def _parse_completion_body(data: dict) -> str:
if not isinstance(data, dict):
raise LLMError(f"Invalid API response: expected object, got {type(data).__name__}")
if data.get("error"):
err = data["error"]
if isinstance(err, dict):
msg = err.get("message") or str(err)
code = err.get("code")
else:
msg = str(err)
code = None
suffix = f" (code={code})" if code is not None else ""
raise LLMError(f"OpenRouter error{suffix}: {msg}")
choices = data.get("choices")
if not choices:
preview = str(data)[:400]
raise LLMError(f"OpenRouter response has no 'choices'. Body preview: {preview}")
first = choices[0] if isinstance(choices[0], dict) else {}
message = first.get("message") or {}
if not isinstance(message, dict):
raise LLMError("OpenRouter choice has no message object")
finish = first.get("finish_reason") or ""
native_finish = first.get("native_finish_reason") or ""
blocked_reasons = {"content_filter", "safety", "moderation"}
if finish in blocked_reasons or str(native_finish).upper() in (
"PROHIBITED_CONTENT",
"SAFETY",
"BLOCKED",
):
raise LLMError(
f"Content blocked by provider (finish_reason={finish}, native={native_finish})"
)
content = message.get("content")
if content is not None and str(content).strip():
return str(content)
refusal = message.get("refusal")
if refusal:
raise LLMError(f"Model refused the request: {refusal}")
if finish and finish not in ("stop", "length", "tool_calls", "function_call"):
raise LLMError(
f"OpenRouter finished without content (finish_reason={finish}, native={native_finish})"
)
raise LLMError("OpenRouter returned empty message content")
def _clean(messages: list) -> list:
"""Filter out messages with empty content."""
return [m for m in messages if (m.get("content") or "").strip()]
async def _post(model: str, messages: list, extra: dict | None = None) -> str:
async def _post_once(model: str, messages: list, extra: dict | None = None) -> str:
if not OPENROUTER_KEY:
raise LLMError("ROUTER_KEY is not set in environment")
payload = {"model": model, "messages": _clean(messages), **(extra or {})}
async with httpx.AsyncClient(timeout=90) as client:
r = await client.post(OPENROUTER_URL, headers=HEADERS, json=payload)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
try:
data = r.json()
except Exception as e:
raise LLMError(f"Non-JSON response (HTTP {r.status_code}): {r.text[:300]}") from e
if r.status_code >= 400:
try:
_parse_completion_body(data)
except LLMError:
raise
raise LLMError(f"HTTP {r.status_code}: {data}")
try:
return _parse_completion_body(data)
except LLMError:
logger.warning(
"OpenRouter completion failed model=%s status=%s body=%.500s",
model,
r.status_code,
data,
)
raise
async def _post(model: str, messages: list, extra: dict | None = None) -> str:
"""POST completion; on failure retries once with LLM_FALLBACK_MODEL (usually CHAT_MODEL)."""
try:
return await _post_once(model, messages, extra)
except LLMError as primary_err:
fallback = LLM_FALLBACK_MODEL
if not fallback or fallback == model:
raise
logger.info(
"LLM fallback: %s failed (%s) → retrying with %s",
model,
primary_err,
fallback,
)
try:
return await _post_once(fallback, messages, extra)
except LLMError as fallback_err:
raise LLMError(
f"{primary_err} (fallback {fallback} also failed: {fallback_err})"
) from fallback_err
async def send_message(messages: list) -> str:
"""System model — narrator, facts, SD prompt."""
"""SYSTEM_MODEL with automatic fallback to LLM_FALLBACK_MODEL."""
return await _post(SYSTEM_MODEL, messages)
async def send_message_with_model(messages: list, model: str) -> str:
"""Explicit model — plot arc, narrator override."""
"""Named model (RPG_*, SD_*) with automatic fallback to LLM_FALLBACK_MODEL."""
return await _post(model, messages)
@@ -73,10 +177,19 @@ async def stream_message(messages: list):
return
try:
chunk = json.loads(data)
content = chunk["choices"][0]["delta"].get("content", "")
if chunk.get("error"):
err = chunk["error"]
msg = err.get("message", err) if isinstance(err, dict) else err
raise LLMError(f"OpenRouter stream error: {msg}")
choices = chunk.get("choices") or []
if not choices:
continue
content = (choices[0].get("delta") or {}).get("content", "")
if content:
chunk_count += 1
yield content
except LLMError:
raise
except Exception:
continue
except Exception as e:
+124 -17
View File
@@ -2,7 +2,8 @@ import aiosqlite
from database.db import DB_PATH
async def get_or_create_session(session_id: str, persona_id: str = "default") -> dict:
async def get_or_create_session(session_id: str, persona_id: str | None = None) -> dict:
"""Existing sessions keep their persona_id; persona_id applies only on INSERT."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
@@ -13,9 +14,10 @@ async def get_or_create_session(session_id: str, persona_id: str = "default") ->
if row:
return dict(row)
pid = (persona_id or "default").strip() or "default"
await db.execute(
"INSERT INTO sessions (session_id, persona_id) VALUES (?, ?)",
(session_id, persona_id),
(session_id, pid),
)
await db.commit()
@@ -71,24 +73,99 @@ async def update_session_persona(session_id: str, persona_id: str):
(persona_id, session_id),
)
# If persona changed, reset RPG state bound to the persona/arc.
if prev is not None and prev != persona_id:
await db.execute(
"""UPDATE sessions
SET facts_json = '[]',
global_plot = '',
status_quo = '',
plot_arc_json = '{}'
WHERE session_id = ?""",
(session_id,),
)
await db.execute(
"DELETE FROM action_resolutions WHERE session_id = ?",
(session_id,),
)
await _reset_persona_bound_state(db, session_id)
await db.commit()
async def _reset_persona_bound_state(db: aiosqlite.Connection, session_id: str) -> None:
await db.execute(
"""UPDATE sessions
SET facts_json = '[]',
global_plot = '',
status_quo = '',
plot_arc_json = '{}',
outfit_json = '[]',
affinity = 0
WHERE session_id = ?""",
(session_id,),
)
await db.execute("DELETE FROM action_resolutions WHERE session_id = ?", (session_id,))
await db.execute("DELETE FROM rpg_quests WHERE session_id = ?", (session_id,))
async def upsert_static_system_message(
session_id: str, static_prompt: str, history: list | None = None
) -> bool:
"""Store only static persona prompt in messages. Returns True if written."""
hist = history if history is not None else await get_history(session_id)
async with aiosqlite.connect(DB_PATH) as db:
if not hist:
await db.execute(
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path)
VALUES (?, 'system', ?, NULL, NULL)""",
(session_id, static_prompt),
)
await db.execute(
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(session_id,),
)
await db.commit()
return True
if hist[0]["role"] == "system":
if hist[0]["content"] == static_prompt:
return False
await db.execute(
"""UPDATE messages SET content = ?
WHERE session_id = ? AND role = 'system'
AND id = (SELECT MIN(id) FROM messages WHERE session_id = ?)""",
(static_prompt, session_id, session_id),
)
await db.commit()
return True
await db.execute(
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path)
VALUES (?, 'system', ?, NULL, NULL)""",
(session_id, static_prompt),
)
await db.commit()
return True
async def delete_dialog_messages(session_id: str) -> None:
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"DELETE FROM messages WHERE session_id = ? AND role IN ('user', 'assistant')",
(session_id,),
)
await db.commit()
async def rebind_session_persona(
session_id: str,
persona_id: str,
*,
clear_history: bool = False,
static_prompt: str,
first_mes: str | None = None,
) -> None:
session = await get_session(session_id)
if not session:
raise ValueError("Session not found")
await update_session_persona(session_id, persona_id)
if clear_history:
await delete_dialog_messages(session_id)
history = await get_history(session_id)
await upsert_static_system_message(session_id, static_prompt, history)
if clear_history and first_mes and first_mes.strip():
await add_message(session_id, "assistant", first_mes.strip())
async def update_session_rpg(session_id: str, rpg_enabled: bool):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
@@ -178,7 +255,8 @@ async def get_history(session_id: str) -> list:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT id, role, content, image_prompt, image_path
"""SELECT id, role, content, image_prompt, image_path,
image_prompt_alt, image_path_alt
FROM messages WHERE session_id = ? ORDER BY id""",
(session_id,),
) as cursor:
@@ -190,6 +268,8 @@ async def get_history(session_id: str) -> list:
"content": r["content"],
"image_prompt": r["image_prompt"],
"image_path": r["image_path"],
"image_prompt_alt": r["image_prompt_alt"],
"image_path_alt": r["image_path_alt"],
}
for r in rows
]
@@ -332,6 +412,33 @@ async def update_message_image(message_id: int, image_path: str):
await db.commit()
async def update_message_prompt(message_id: int, image_prompt: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE messages SET image_prompt = ? WHERE id = ?",
(image_prompt, message_id),
)
await db.commit()
async def update_message_prompt_alt(message_id: int, image_prompt_alt: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE messages SET image_prompt_alt = ? WHERE id = ?",
(image_prompt_alt, message_id),
)
await db.commit()
async def update_message_image_alt(message_id: int, image_path_alt: str):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE messages SET image_path_alt = ? WHERE id = ?",
(image_path_alt, message_id),
)
await db.commit()
async def get_last_assistant_message_id(session_id: str) -> int | None:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
+178
View File
@@ -0,0 +1,178 @@
import json
import logging
from services.memory import (
get_history,
get_session,
get_last_assistant_message_id,
update_session_plot_arc,
update_session_status_quo,
update_session_affinity,
update_session_outfit,
upsert_quest,
get_quests,
)
from services.personas import get_persona
from services.rpg_facts import facts_to_prompt
from services.rpg_plot import generate_plot_arc
from services.rpg_narrator import narrator_post
from services.sd_prompt import generate_sd_prompt
from services.sd_images import run_sd_for_message
logger = logging.getLogger(__name__)
DEFAULT_RPG_SETTINGS = {
"dice": True,
"narrator": True,
"quests": True,
"affinity": True,
"choices": True,
}
def get_rpg_settings(session: dict) -> dict:
try:
return {**DEFAULT_RPG_SETTINGS, **json.loads(session.get("rpg_settings_json") or "{}")}
except Exception:
return DEFAULT_RPG_SETTINGS
async def resolve_greeting(session_id: str, persona: dict) -> str:
history = await get_history(session_id)
for m in reversed(history):
if m.get("role") == "assistant" and (m.get("content") or "").strip():
return m["content"].strip()
return (persona.get("first_mes") or "").strip()
async def ensure_plot_arc_and_quests(
session_id: str,
persona: dict,
greeting: str,
genre: str,
*,
seed_quests: bool = True,
) -> dict:
session = await get_session(session_id) or {}
arc_json = session.get("plot_arc_json") or "{}"
try:
arc = json.loads(arc_json) if isinstance(arc_json, str) else {}
except Exception:
arc = {}
if arc:
return arc
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
arc = await generate_plot_arc(
persona.get("name", "Character"),
persona.get("description", ""),
persona.get("scenario", ""),
greeting,
facts_block=facts_block,
genre=genre,
)
if not arc:
return {}
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
if seed_quests:
for beat in arc.get("beats", []):
title = (beat.get("title") or beat.get("injection", "")).strip()
if title:
await upsert_quest(session_id, title[:120])
return arc
async def process_opening(session_id: str, persona_id: str, *, rpg: bool) -> dict:
session = await get_session(session_id)
if not session:
raise ValueError("Session not found")
history = await get_history(session_id)
assistant_msgs = [m for m in history if m.get("role") == "assistant"]
if not assistant_msgs:
raise ValueError("No assistant message (first_mes) found")
first_mes_text = assistant_msgs[-1].get("content", "").strip()
if not first_mes_text:
raise ValueError("Empty first_mes")
msg_id = await get_last_assistant_message_id(session_id)
persona = await get_persona(persona_id) or {}
rpg_settings = get_rpg_settings(session)
arc: dict = {}
choices: list = []
status_quo = session.get("status_quo") or ""
outfit_json = session.get("outfit_json") or "[]"
if rpg:
genre = session.get("genre") or "adventure"
arc = await ensure_plot_arc_and_quests(
session_id,
persona,
first_mes_text,
genre,
seed_quests=rpg_settings.get("quests", True),
)
session = await get_session(session_id) or session
ctx_txt = f"assistant: {first_mes_text}"
arc_json = json.dumps(arc, ensure_ascii=False) if arc else ""
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
post = await narrator_post(
persona.get("name", persona_id),
ctx_txt,
arc_json,
facts_block,
is_opening=True,
)
sq = (post.get("status_quo_update") or "").strip()
if sq:
await update_session_status_quo(session_id, sq)
status_quo = sq
if rpg_settings.get("choices", True):
choices = post.get("choices") or []
if rpg_settings.get("affinity", True):
delta = int(post.get("affinity_delta") or 0)
if delta:
await update_session_affinity(session_id, delta)
outfit_update = post.get("outfit_update")
if isinstance(outfit_update, list) and outfit_update:
outfit_json = json.dumps(outfit_update, ensure_ascii=False)
await update_session_outfit(session_id, outfit_json)
if rpg_settings.get("quests", True):
for qu in (post.get("quest_updates") or []):
title = (qu.get("title") or "").strip()
if title:
await upsert_quest(session_id, title[:120], qu.get("status", "active"))
quests = await get_quests(session_id)
messages = await get_history(session_id)
bundle = await generate_sd_prompt(messages, persona_id, outfit_json=outfit_json)
sd_out = await run_sd_for_message(bundle, msg_id) if bundle else {}
updated = await get_session(session_id)
affinity = updated.get("affinity", 0) if updated else 0
return {
"plot_arc": arc,
"quests": quests,
"outfit_json": outfit_json,
"status_quo": status_quo,
"choices": choices,
"image_prompt": sd_out.get("image_prompt"),
"image_prompt_alt": sd_out.get("image_prompt_alt"),
"image_path": sd_out.get("image_path"),
"image_path_alt": sd_out.get("image_path_alt"),
"image_error": sd_out.get("image_error"),
"image_error_alt": sd_out.get("image_error_alt"),
"affinity": affinity,
}
+21 -4
View File
@@ -63,6 +63,7 @@ def _row_to_persona(row: dict) -> dict:
"lora_name": row["lora_name"] or "",
"lora_weight": row["lora_weight"] if row["lora_weight"] is not None else 0.8,
"appearance_tags": row["appearance_tags"] or "",
"appearance_prose": row.get("appearance_prose", "") or "",
"personality": row.get("personality", "") or "",
"scenario": row.get("scenario", "") or "",
"first_mes": row.get("first_mes", "") or "",
@@ -117,6 +118,7 @@ async def create_persona(
lora_name: str = "",
lora_weight: float = 0.8,
appearance_tags: str = "",
appearance_prose: str = "",
personality: str = "",
scenario: str = "",
first_mes: str = "",
@@ -138,19 +140,19 @@ async def create_persona(
await db.execute(
"""INSERT INTO personas
(persona_id, name, emoji, description, prompt, custom,
sd_enabled, lora_name, lora_weight, appearance_tags,
sd_enabled, lora_name, lora_weight, appearance_tags, appearance_prose,
personality, scenario, first_mes, mes_example, lorebook_json, avatar_path,
alternate_greetings_json)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
persona_id, name, emoji, description, final_prompt,
1 if sd_enabled else 0, lora_name, lora_weight, appearance_tags,
1 if sd_enabled else 0, lora_name, lora_weight, appearance_tags, appearance_prose,
personality, scenario, first_mes, mes_example, lorebook_json, avatar_path,
alternate_greetings_json,
),
)
await db.commit()
return {
return {
"name": name,
"emoji": emoji,
"description": description,
@@ -160,6 +162,7 @@ async def create_persona(
"lora_name": lora_name,
"lora_weight": lora_weight,
"appearance_tags": appearance_tags,
"appearance_prose": appearance_prose,
"personality": personality,
"scenario": scenario,
"first_mes": first_mes,
@@ -226,6 +229,7 @@ async def patch_persona(persona_id: str, fields: dict) -> bool:
"lora_name",
"lora_weight",
"appearance_tags",
"appearance_prose",
"personality",
"scenario",
"first_mes",
@@ -255,6 +259,19 @@ async def patch_persona(persona_id: str, fields: dict) -> bool:
merged = dict(existing)
merged.update(updates)
updates["prompt"] = build_persona_prompt(merged)
if "appearance_tags" in updates and "appearance_prose" not in updates:
tags = updates["appearance_tags"].strip()
if tags:
from services.llm import send_message
try:
prose = await send_message([
{"role": "system", "content": "Convert danbooru tags to natural English description. Output only the description, no markdown."},
{"role": "user", "content": f"Tags: {tags}"}
])
updates["appearance_prose"] = prose.strip()
except Exception:
pass
cols = ", ".join(f"{k} = ?" for k in updates)
cur2 = await db.execute(
+17 -2
View File
@@ -1,7 +1,10 @@
import json
import logging
import os
from services.llm import send_message_with_model, send_message
from services.llm import LLMError, send_message_with_model, send_message
logger = logging.getLogger(__name__)
FACTS_MODEL = os.getenv("RPG_FACTS_MODEL", "").strip() or "deepseek/deepseek-chat-v3"
@@ -51,7 +54,19 @@ async def extract_facts(context_messages: list[dict]) -> list[str]:
{"role": "user", "content": transcript},
]
raw = await (send_message_with_model(messages, FACTS_MODEL) if FACTS_MODEL else send_message(messages))
try:
raw = await (
send_message_with_model(messages, FACTS_MODEL)
if FACTS_MODEL
else send_message(messages)
)
except LLMError as e:
logger.warning("extract_facts LLM failed (model=%s): %s", FACTS_MODEL or "SYSTEM", e)
return []
except Exception as e:
logger.warning("extract_facts unexpected error: %s", e)
return []
try:
data = json.loads(raw.strip())
if isinstance(data, list):
+35 -9
View File
@@ -2,7 +2,7 @@ import json
import os
import random
from services.llm import send_message_with_model
from services.llm import LLMError, send_message_with_model
import logging
logger = logging.getLogger(__name__)
@@ -63,10 +63,18 @@ async def narrator_pre(
f"Facts:\n{facts_block}\n\n"
f"Recent context:\n{context}\n"
)
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}],
NARRATOR_MODEL,
)
try:
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}],
NARRATOR_MODEL,
)
except LLMError as e:
logger.warning("Narrator-pre LLM failed (model=%s): %s", NARRATOR_MODEL, e)
return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": ""}
except Exception as e:
logger.warning("Narrator-pre unexpected error: %s", e)
return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": ""}
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
@@ -87,17 +95,35 @@ async def narrator_post(
context: str,
global_plot: str,
facts_block: str,
is_opening: bool = False,
) -> dict:
opening_block = ""
if is_opening:
opening_block = (
"\n\nOPENING SCENE: This is the first greeting, not a mid-conversation reply. "
"Extract the character's INITIAL visible clothing from the greeting into outfit_update "
"(danbooru underscore tags), even if clothing did not change during the scene. "
"Set status_quo to describe the opening situation.\n"
)
user = (
f"Persona: {persona_name}\n\n"
f"Global plot:\n{global_plot}\n\n"
f"Facts:\n{facts_block}\n\n"
f"Recent context:\n{context}\n"
f"{opening_block}"
)
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
NARRATOR_MODEL,
)
try:
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
NARRATOR_MODEL,
)
except LLMError as e:
logger.warning("Narrator-post LLM failed (model=%s): %s", NARRATOR_MODEL, e)
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": []}
except Exception as e:
logger.warning("Narrator-post unexpected error: %s", e)
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": []}
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
+14 -2
View File
@@ -1,7 +1,7 @@
import json
import os
from services.llm import send_message_with_model, send_message
from services.llm import LLMError, send_message_with_model, send_message
import logging
logger = logging.getLogger(__name__)
@@ -63,7 +63,19 @@ async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenar
{"role": "system", "content": ARC_SYSTEM},
{"role": "user", "content": user},
]
raw = await (send_message_with_model(messages, PLOT_MODEL) if PLOT_MODEL else send_message(messages))
try:
raw = await (
send_message_with_model(messages, PLOT_MODEL)
if PLOT_MODEL
else send_message(messages)
)
except LLMError as e:
logger.warning("generate_plot_arc LLM failed (model=%s): %s", PLOT_MODEL or "SYSTEM", e)
return {}
except Exception as e:
logger.warning("generate_plot_arc unexpected error: %s", e)
return {}
cleaned = raw.strip()
# common OpenRouter formatting: fenced json
if cleaned.startswith("```"):
+48
View File
@@ -0,0 +1,48 @@
"""Run ComfyUI generation from SdPromptBundle (single hybrid prompt for Anima)."""
import logging
import os
from services import sdbackend as sd_service
from services.memory import update_message_image, update_message_prompt, update_message_prompt_alt
from services.sd_prompt import SdPromptBundle
logger = logging.getLogger(__name__)
SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes")
async def run_sd_for_message(bundle: SdPromptBundle | None, msg_id: int | None) -> dict:
"""Generate image, persist prompts/paths on message. Returns fields for API/SSE."""
out = {
"image_prompt": None,
"image_prompt_alt": None,
"image_path": None,
"image_path_alt": None,
"image_error": None,
"image_error_alt": None,
}
if not bundle or not bundle.tag_full:
return out
out["image_prompt"] = bundle.tag_full
if bundle.desc_full and bundle.desc_full != bundle.tag_full:
out["image_prompt_alt"] = bundle.desc_full
if msg_id:
await update_message_prompt(msg_id, bundle.tag_full)
if out["image_prompt_alt"]:
await update_message_prompt_alt(msg_id, out["image_prompt_alt"])
if not SD_AUTO_GENERATE:
return out
rel, err = await sd_service.generate_from_full_prompt(bundle.tag_full)
if rel:
out["image_path"] = f"/static/{rel}"
if msg_id:
await update_message_image(msg_id, rel)
else:
out["image_error"] = err
return out
+641 -67
View File
@@ -2,26 +2,115 @@ import json
import logging
import os
import re
from dataclasses import dataclass
from services.llm import send_message, send_message_with_model
from services.personas import get_persona
logger = logging.getLogger(__name__)
NEGATIVE_PROMPT_SEPARATOR = "\n\n__NEGATIVE_PROMPT__\n\n"
PROMPT_BUILDER_SYSTEM = """You are a Stable Diffusion prompt engineer for anime illustration models.
Given a roleplay chat excerpt, output ONLY valid JSON (no markdown):
{
"should_generate": true,
"shot_type": "first_person_pov" | "landscape" | "third_person",
"action_tags": "booru-style tags for pose/action/expression, e.g. 'sitting, smiling, holding_cup'",
"environment_tags": "booru-style tags for location/lighting/time, e.g. 'indoors, kitchen, sunlight, daytime'"
"action_tags": "booru-style tags for pose/action/expression",
"environment_tags": "booru-style tags for location/lighting/time"
}
Rules:
- ONLY use real danbooru/e621 tags. Multi-word concepts MUST be underscore_joined: 'fox_ears' not 'fox ears'.
- ONLY use real danbooru/e621 tags. Multi-word concepts MUST be underscore_joined.
- Do NOT include appearance/character tags — those are provided separately.
- Do NOT include quality tags, model names, style words, 'pov', or category/metadata words.
- Do NOT invent tags. If unsure — omit.
- Keep each field to 3-6 tags."""
- Keep action_tags and environment_tags to 3-6 tags each.
- shot_type: default "first_person_pov" for dialogue/intimacy at arm's length. "third_person" only for wide action (fight, chase). "landscape" only when environment is the focus.
- should_generate: false for non-visual beats (pure internal monologue, time skips with no new pose, empty lines).
- NEVER use negative words in tag fields (not, without, naked, nsfw, etc.)."""
ANIMA_BUILDER_EXTRA = """
Anima hybrid mode — ALSO include:
"pov_cue": "face_to_face" | "walking_together" | "doorway_invite" | "reach_to_viewer" | "dialogue_close",
"viewer_body_visible": false,
"scene_description": "ONE short English sentence (max 40 words). Camera POV: what the viewer sees. Mood/atmosphere only — do NOT repeat tags from action_tags/environment_tags. Do NOT list comma-separated booru tags."
POV / interaction rules:
- Default viewer_body_visible: false. The viewer's body, hands, or face must NOT appear in the image — only the character toward the camera.
- For hugs, embraces: use arms_out, reaching_towards_viewer, inviting_hug — NOT holding_hands, lifting, carrying, nose_rub (these draw a second body in POV).
- For long messages with time skips ("About an hour later..."), illustrate ONLY the final visible beat (usually the last paragraph).
- scene_description: describe HER toward the camera only — NEVER "someone", "both", "with you", "hand in hand with", or another person's body.
- NEVER use tags: looking_at_each_other, couple, 2girls, 2boys, multiple_girls. For POV walking together omit holding_hands (use walking, smiling, reaching_towards_viewer instead).
- pov_cue: pick the framing that matches the CURRENT beat (walking_together for strolling side by side, doorway_invite for doorway with arms open, reach_to_viewer when she reaches toward camera, face_to_face for close dialogue).
- Illustrate ONLY the beat under === ILLUSTRATE ===; use === Context === for outfit/location hints only.
- Do NOT put English sentences in action_tags or environment_tags — tags only."""
POV_CUE_PHRASES: dict[str, str] = {
"face_to_face": "POV: close face-to-face, she looks directly at you",
"walking_together": "POV: walking beside you, profile and shared path visible",
"doorway_invite": "POV: she blocks the doorway, arms open toward you",
"reach_to_viewer": "POV: she reaches toward the camera",
"dialogue_close": "POV: close conversation, she faces you at arm's length",
}
POV_CUE_DEFAULT = "POV: she stands before you, facing the camera"
POV_INTERACTION_NEGATIVE = (
"duplicate, clone, multiple_girls, 2girls, extra_person, pov hands, "
"disembodied hands, extra arms, second person"
)
_CONTACT_ACTION_KEYWORDS = (
"hug", "holding_hands", "hand_holding", "arms_out", "embrace",
"reaching", "inviting_hug", "arm_around", "cuddling",
)
_JUNK_STANDALONE_TAGS = frozenset({
"white", "black", "skin", "ear", "ears", "girl", "boy", "fox", "wolf", "cat",
"short", "tall", "slim", "golden", "silver", "red", "blue", "green", "purple",
"pink", "brown", "blonde", "eye", "eyes", "hair",
})
_INVALID_TAGS = frozenset({
"pumped_up", "pumped", "looking_at_each_other", "couple",
"2girls", "2boys", "multiple_girls", "multiple_boys", "duo",
})
_POV_DROP_ACTION_TAGS = frozenset({
"holding_hands", "hand_holding", "looking_at_each_other", "couple",
"lifting", "carry", "carrying", "princess_carry", "nose_rub", "nose_boop",
})
_TIME_SKIP_RE = re.compile(
r"(?i)\b(?:about an hour later|hours later|later that (?:day|evening|night)|"
r"the next (?:day|morning|evening)|meanwhile|after (?:some )?time)\b[.…\s]*",
)
_POV_MOOD_FALLBACK: dict[str, str] = {
"walking_together": "Easy warmth and quiet laughter in the afternoon light.",
"doorway_invite": "Cool air and playful tension as she waits in the doorway.",
"reach_to_viewer": "A charged moment as she reaches toward the camera.",
"face_to_face": "Her expression softens in close focus toward the camera.",
"dialogue_close": "Intimate calm in the space between you.",
}
_INDOOR_ENV_MARKERS = frozenset({"doorway", "indoors", "indoor", "apartment", "inside", "room"})
_OUTDOOR_ENV_MARKERS = frozenset({"outdoor", "outdoors", "outside", "street"})
_POV_PROSE_BANNED = re.compile(
r"\b(someone|both|together with|hand in hand with|another person|second person|"
r"your hands|your fingers|your embrace|your heat|intertwined|with you|"
r"demands your|before you)\b",
re.IGNORECASE,
)
SD_ANIMA_DUAL_COMPARE = os.getenv("SD_ANIMA_DUAL_COMPARE", "false").lower() in ("1", "true", "yes")
@dataclass
class SdPromptBundle:
tag_full: str
negative: str
desc_full: str | None = None
def extract_image_prompt_tag(text: str) -> str | None:
@@ -44,7 +133,7 @@ SD_UNET = os.getenv("SD_UNET", "")
SD_PROMPT_MODEL = os.getenv("SD_PROMPT_MODEL", "").strip()
PONY_CHECKPOINTS = {"ponyDiffusionV6XL_v6StartWithThisOne.safetensors"}
PONY_NEGATIVE = "score_1, score_2, score_3, score_4, worst quality, low quality, blurry, bad anatomy, watermark, text, censored"
PONY_NEGATIVE = "score_1, score_2, score_3, score_4, worst quality, low quality, blurry, bad anatomy, watermark, text, censored"
ANIMA_NEGATIVE = "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia"
@@ -56,37 +145,201 @@ def _is_anima() -> bool:
return bool(SD_UNET) and not SD_CHECKPOINT
def build_positive_prompt(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
def anima_dual_enabled() -> bool:
return _is_anima() and SD_ANIMA_DUAL_COMPARE
def _builder_system() -> str:
if _is_anima():
return PROMPT_BUILDER_SYSTEM + ANIMA_BUILDER_EXTRA
return PROMPT_BUILDER_SYSTEM
def _normalize_shot_type(scene: dict) -> dict:
st = (scene.get("shot_type") or "").strip().lower()
if st == "landscape":
scene["shot_type"] = "landscape"
return _sanitize_scene_fields(scene)
if st == "third_person":
action = (scene.get("action_tags") or "").lower()
wide = ("battle", "fight", "chase", "running", "crowd", "wide_shot", "group_shot")
if any(w in action for w in wide):
scene["shot_type"] = "third_person"
return _sanitize_scene_fields(scene)
scene["shot_type"] = "first_person_pov"
if scene.get("viewer_body_visible") is None:
scene["viewer_body_visible"] = False
return _sanitize_scene_fields(scene)
def _split_tag_input(tag_str: str) -> list[str]:
return [t.strip() for t in (tag_str or "").split(",") if t.strip()]
def _is_sentence_like_tag(tag: str) -> bool:
t = tag.strip()
if len(t) > 45:
return True
if re.search(r"[.!?]", t):
return True
words = t.split()
return len(words) >= 5 and "_" not in t
def _filter_tag_field(tag_str: str, *, for_pov: bool, field: str) -> str:
kept: list[str] = []
for raw in _split_tag_input(tag_str):
key = raw.lower().replace(" ", "_")
if key in _INVALID_TAGS:
continue
if _is_sentence_like_tag(raw):
continue
if for_pov and field == "action" and key in _POV_DROP_ACTION_TAGS:
continue
kept.append(raw if "_" in raw else key)
return ", ".join(kept)
def _reconcile_environment_tags(env_str: str) -> str:
tags = _split_tag_input(env_str)
keys = {t.lower().replace(" ", "_") for t in tags}
has_indoor = bool(keys & _INDOOR_ENV_MARKERS) or any(
any(m in k for m in _INDOOR_ENV_MARKERS) for k in keys
)
has_outdoor = bool(keys & _OUTDOOR_ENV_MARKERS) or any(
any(m in k for m in _OUTDOOR_ENV_MARKERS) for k in keys
)
if has_indoor and has_outdoor:
tags = [t for t in tags if t.lower().replace(" ", "_") not in _OUTDOOR_ENV_MARKERS]
return ", ".join(tags)
def _sanitize_pov_prose(desc: str, scene: dict) -> str:
if not desc or not desc.strip():
return ""
if scene.get("shot_type") != "first_person_pov":
return desc.strip()
kept: list[str] = []
for sentence in re.split(r"(?<=[.!?])\s+", desc.strip()):
s = sentence.strip()
if not s:
continue
if _POV_PROSE_BANNED.search(s):
continue
if re.search(r"\bwolfgirl\b", s, re.I) and re.search(
r"\b(walks|walking|stands)\b", s, re.I
):
continue
kept.append(s)
out = " ".join(kept).strip()
return re.sub(r"\bat the viewer\b", "at the camera", out, flags=re.IGNORECASE)
def _sanitize_scene_fields(scene: dict) -> dict:
scene = dict(scene)
for_pov = scene.get("shot_type") == "first_person_pov"
scene["action_tags"] = _filter_tag_field(
scene.get("action_tags") or "", for_pov=for_pov, field="action"
)
env = _filter_tag_field(scene.get("environment_tags") or "", for_pov=False, field="env")
scene["environment_tags"] = _reconcile_environment_tags(env)
scene["scene_description"] = _sanitize_pov_prose(
(scene.get("scene_description") or "").strip(), scene
)
return scene
def _scene_should_generate(scene: dict) -> bool:
if scene.get("should_generate") is False:
return False
return True
def _sanitize_tags_string(tag_str: str) -> str:
if not tag_str:
return ""
out: list[str] = []
seen: set[str] = set()
for raw in tag_str.split(","):
t = raw.strip()
if not t:
continue
key = t.lower().replace(" ", "_")
if key in seen:
continue
if key in _INVALID_TAGS:
continue
if "_" not in key and key in _JUNK_STANDALONE_TAGS:
continue
if len(key) <= 2:
continue
seen.add(key)
out.append(t if "_" in t else key)
return ", ".join(out)
def _quality_prefix() -> str:
if _is_pony():
quality = "score_9, score_8_up, score_7_up, source_anime, highres"
elif _is_anima():
quality = "masterpiece, best quality, score_7, anime"
else:
quality = "masterpiece, best quality, highres"
return "score_9, score_8_up, score_7_up, source_anime, highres"
if _is_anima():
return "masterpiece, best quality, score_7, anime"
return "masterpiece, best quality, highres"
parts = [quality]
appearance = (persona or {}).get("appearance_tags", "")
if appearance:
parts.append(appearance)
if outfit_tags:
parts.append(outfit_tags)
def _appearance_for_persona(persona: dict | None) -> str:
"""Tag core uses appearance_tags only (prose is for LLM context, not Comfy tag line)."""
return _sanitize_tags_string((persona or {}).get("appearance_tags", ""))
if scene.get("shot_type") == "landscape":
parts.append(scene.get("environment_tags", ""))
else:
if scene.get("shot_type") == "first_person_pov":
parts.append("pov, first-person view, looking at viewer")
parts.append(scene.get("action_tags", ""))
parts.append(scene.get("environment_tags", ""))
def _dedupe_outfit_tags(outfit_tags: str) -> str:
tags = _split_tag_input(outfit_tags)
keys = {t.lower().replace(" ", "_") for t in tags}
if len(keys & {"jeans", "ripped_jeans", "black_jeans"}) > 1 and "jeans" in keys:
tags = [t for t in tags if t.lower().replace(" ", "_") != "jeans"]
return ", ".join(tags)
def _scene_has_physical_contact(scene: dict) -> bool:
action = (scene.get("action_tags") or "").lower()
return any(k in action for k in _CONTACT_ACTION_KEYWORDS)
def _infer_pov_cue_from_action(action_tags: str) -> str:
action = (action_tags or "").lower()
if any(k in action for k in ("holding_hands", "hand_holding", "walking", "strolling")):
return "walking_together"
if any(k in action for k in ("doorway", "door", "entry", "threshold")):
if any(k in action for k in ("arms_out", "hug", "embrace", "inviting")):
return "doorway_invite"
if any(k in action for k in ("arms_out", "reaching", "inviting_hug", "hug", "embrace")):
return "reach_to_viewer"
if any(k in action for k in ("sitting", "lying", "bed")):
return "dialogue_close"
return "face_to_face"
def _build_pov_phrase(scene: dict) -> str:
if scene.get("shot_type") != "first_person_pov":
return ""
cue = (scene.get("pov_cue") or "").strip().lower().replace("-", "_").replace(" ", "_")
if cue in POV_CUE_PHRASES:
return POV_CUE_PHRASES[cue]
inferred = _infer_pov_cue_from_action(scene.get("action_tags", ""))
return POV_CUE_PHRASES.get(inferred, POV_CUE_DEFAULT)
def _append_lora(parts: list[str], persona: dict | None) -> None:
lora = (persona or {}).get("lora_name", "")
weight = (persona or {}).get("lora_weight", 0.8)
if lora:
parts.append(f"<lora:{lora}:{weight}>")
def _dedupe_comma_join(parts: list[str]) -> str:
positive = ", ".join(p.strip() for p in parts if p and p.strip())
seen, deduped = set(), []
seen: set[str] = set()
deduped: list[str] = []
for tag in positive.split(", "):
t = tag.strip()
if t and t not in seen:
@@ -95,53 +348,152 @@ def build_positive_prompt(scene: dict, persona: dict | None, outfit_tags: str =
return ", ".join(deduped)
async def generate_sd_prompt(
messages: list,
persona_id: str,
outfit_json: str = "[]",
) -> tuple[str | None, str | None]:
persona = await get_persona(persona_id)
# Generate only if persona has appearance tags
if not persona or not (persona.get("appearance_tags") or "").strip():
logger.debug("sd_prompt skip: persona=%s no appearance_tags", persona_id)
return None, None
def _build_tag_core(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
"""Anchor + structure: quality, appearance, outfit, action/env tags, LoRA. No POV prose, no scene_description."""
parts = [_quality_prefix()]
appearance = _appearance_for_persona(persona)
if appearance:
parts.append(appearance)
if outfit_tags:
parts.append(_sanitize_tags_string(_dedupe_outfit_tags(outfit_tags)))
if scene.get("shot_type") == "landscape":
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
else:
if not _is_anima() and scene.get("shot_type") == "first_person_pov":
parts.append("pov, first-person view, looking at viewer")
parts.append(_sanitize_tags_string(scene.get("action_tags", "")))
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
_append_lora(parts, persona)
return _dedupe_comma_join(parts)
recent = [m for m in messages if m["role"] in ("user", "assistant")][-6:]
if not recent:
return None, None
excerpt = "\n".join(f"{m['role']}: {strip_image_prompt_tag(m['content'])}" for m in recent)
def build_positive_prompt_tags_only(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
"""Tags + contextual POV phrase (Anima) or legacy Pony path."""
if not _is_anima():
return build_positive_prompt(scene, persona, outfit_tags)
core = _build_tag_core(scene, persona, outfit_tags)
pov = _build_pov_phrase(scene)
if pov:
return f"{core}, {pov}" if core else pov
return core
builder_messages = [
{"role": "system", "content": PROMPT_BUILDER_SYSTEM},
{"role": "user", "content": f"Chat:\n{excerpt}"},
]
try:
if SD_PROMPT_MODEL:
raw = await send_message_with_model(builder_messages, SD_PROMPT_MODEL)
else:
raw = await send_message(builder_messages)
raw = raw.strip()
if raw.startswith("```"):
raw = re.sub(r"^```\w*\n?", "", raw)
raw = re.sub(r"\n?```$", "", raw)
scene = json.loads(raw)
if not isinstance(scene, dict):
logger.warning("sd_prompt: LLM returned non-dict: %.100s", raw)
return None, None
except Exception as e:
logger.warning("sd_prompt failed: %s raw=%.200s", e, locals().get("raw", ""))
return None, None
def _tag_tokens_for_dedupe(tag_line: str) -> set[str]:
tokens: set[str] = set()
for part in tag_line.replace("<lora:", " ").split(","):
for word in re.split(r"[\s_./]+", part.lower()):
w = word.strip()
if len(w) >= 4:
tokens.add(w)
return tokens
try:
outfit_list = json.loads(outfit_json or "[]")
outfit_tags = ", ".join(outfit_list) if isinstance(outfit_list, list) else ""
except Exception:
outfit_tags = ""
positive = build_positive_prompt(scene, persona, outfit_tags)
def _trim_redundant_scene_description(desc: str, tag_line: str) -> str:
tag_tokens = _tag_tokens_for_dedupe(tag_line)
if not tag_tokens or not desc.strip():
return desc.strip()
kept: list[str] = []
for sentence in re.split(r"(?<=[.!?])\s+", desc.strip()):
s = sentence.strip()
if not s:
continue
words = [w.lower() for w in re.findall(r"[a-zA-Z]{4,}", s)]
if not words:
kept.append(s)
continue
overlap = sum(1 for w in words if w in tag_tokens) / len(words)
if overlap < 0.62:
kept.append(s)
return " ".join(kept).strip()
def _extract_illustrate_content(content: str, max_chars: int = 1400) -> str:
"""Long assistant posts (first_mes): use final beat after time-skip, last paragraphs."""
text = strip_image_prompt_tag(content).strip()
if not text:
return ""
chunks = _TIME_SKIP_RE.split(text)
if len(chunks) > 1:
text = chunks[-1].strip()
if len(text) <= max_chars:
return text
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
if paragraphs:
for n in (1, 2, 3):
tail = "\n\n".join(paragraphs[-n:])
if len(tail) <= max_chars:
return tail
return paragraphs[-1][-max_chars:]
return text[-max_chars:]
def _fallback_mood_prose(scene: dict) -> str:
cue = (scene.get("pov_cue") or "").strip().lower().replace("-", "_").replace(" ", "_")
if cue in _POV_MOOD_FALLBACK:
return _POV_MOOD_FALLBACK[cue]
inferred = _infer_pov_cue_from_action(scene.get("action_tags", ""))
return _POV_MOOD_FALLBACK.get(inferred, "Soft atmosphere; her expression toward the camera.")
def _cap_scene_description(desc: str, max_words: int = 40, max_chars: int = 220) -> str:
words = desc.split()
if len(words) > max_words:
desc = " ".join(words[:max_words])
if len(desc) > max_chars:
desc = desc[: max_chars - 3] + "..."
return desc
def build_positive_prompt_hybrid(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
"""Production Anima prompt: tag core + POV cue + short mood prose."""
if not _is_anima():
return build_positive_prompt(scene, persona, outfit_tags)
base = build_positive_prompt_tags_only(scene, persona, outfit_tags)
desc = _trim_redundant_scene_description(
(scene.get("scene_description") or "").strip(),
base,
)
desc = _cap_scene_description(desc)
if not desc:
desc = _cap_scene_description(_fallback_mood_prose(scene))
if not desc:
return base
lora = (persona or {}).get("lora_name", "")
weight = (persona or {}).get("lora_weight", 0.8)
lora_suffix = f" <lora:{lora}:{weight}>" if lora else ""
if lora_suffix and base.endswith(lora_suffix):
base = base[: -len(lora_suffix)]
return f"{base}. {desc}{lora_suffix}"
return f"{base}. {desc}"
def build_positive_prompt(scene: dict, persona: dict | None, outfit_tags: str = "") -> str:
"""Legacy entry: Pony/non-Anima full prompt; Anima delegates to tags-only."""
if _is_anima():
return build_positive_prompt_tags_only(scene, persona, outfit_tags)
parts = [_quality_prefix()]
appearance = _appearance_for_persona(persona)
if appearance:
parts.append(appearance)
if outfit_tags:
parts.append(_sanitize_tags_string(_dedupe_outfit_tags(outfit_tags)))
if scene.get("shot_type") == "landscape":
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
else:
if scene.get("shot_type") == "first_person_pov":
parts.append("pov, first-person view, looking at viewer")
parts.append(_sanitize_tags_string(scene.get("action_tags", "")))
parts.append(_sanitize_tags_string(scene.get("environment_tags", "")))
_append_lora(parts, persona)
return _dedupe_comma_join(parts)
def _negative_for_scene(scene: dict) -> str:
if _is_pony():
negative = PONY_NEGATIVE
elif _is_anima():
@@ -151,6 +503,228 @@ async def generate_sd_prompt(
if scene.get("shot_type") == "first_person_pov":
negative += ", third person, over the shoulder"
viewer_visible = scene.get("viewer_body_visible") is True
if not viewer_visible or _scene_has_physical_contact(scene):
negative += ", " + POV_INTERACTION_NEGATIVE
full = positive + f"\n\nNegative prompt: {negative}"
return full, negative
return negative
def _format_builder_user_block(persona: dict, messages: list[dict], outfit_json: str) -> str:
lines: list[str] = []
tags = (persona.get("appearance_tags") or "").strip()
lines.append(f"Character appearance (tags): {tags}")
prose = (persona.get("appearance_prose") or "").strip()
if _is_anima() and prose and prose != tags:
snippet = prose[:300] + ("..." if len(prose) > 300 else "")
lines.append(f"Character notes (do not copy into tags or scene_description): {snippet}")
try:
outfit_list = json.loads(outfit_json or "[]")
outfit_ref = ", ".join(outfit_list) if isinstance(outfit_list, list) else ""
except Exception:
outfit_ref = ""
if outfit_ref:
lines.append(f"Current outfit (tags): {outfit_ref}")
recent = [m for m in messages if m.get("role") in ("user", "assistant")][-6:]
if not recent:
lines.append("\nChat:\n(no messages — return should_generate=false)")
return "\n".join(lines)
illustrate: list[dict] = []
if recent[-1]["role"] == "assistant":
illustrate = [recent[-1]]
if len(recent) >= 2 and recent[-2]["role"] == "user":
illustrate.insert(0, recent[-2])
else:
illustrate = [recent[-1]]
if len(recent) >= 2 and recent[-2]["role"] == "assistant":
illustrate.insert(0, recent[-2])
context = [m for m in recent if m not in illustrate]
lines.append("\n=== ILLUSTRATE (draw THIS beat only) ===")
for m in illustrate:
raw = m.get("content", "")
content = _extract_illustrate_content(raw) if m.get("role") == "assistant" else strip_image_prompt_tag(raw)
lines.append(f"{m['role']}: {content}")
if context:
lines.append("\n=== Context (outfit/location hints only — do not illustrate old beats) ===")
for m in context:
content = strip_image_prompt_tag(m.get("content", ""))
if len(content) > 800:
content = content[:797] + "..."
lines.append(f"{m['role']}: {content}")
return "\n".join(lines)
def _parse_scene_json(raw: str) -> dict:
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```\w*\n?", "", cleaned)
cleaned = re.sub(r"\n?```$", "", cleaned)
scene = json.loads(cleaned)
if not isinstance(scene, dict):
raise ValueError("LLM returned non-object JSON")
return _normalize_shot_type(scene)
def _bundle_from_scene(scene: dict, persona: dict, outfit_tags: str) -> SdPromptBundle:
negative = _negative_for_scene(scene)
if _is_anima():
hybrid = build_positive_prompt_hybrid(scene, persona, outfit_tags)
tag_full = hybrid + NEGATIVE_PROMPT_SEPARATOR + negative
desc_full = None
if anima_dual_enabled():
tags_only = build_positive_prompt_tags_only(scene, persona, outfit_tags)
desc_full = tags_only + NEGATIVE_PROMPT_SEPARATOR + negative
return SdPromptBundle(tag_full=tag_full, negative=negative, desc_full=desc_full)
positive = build_positive_prompt(scene, persona, outfit_tags)
tag_full = positive + NEGATIVE_PROMPT_SEPARATOR + negative
return SdPromptBundle(tag_full=tag_full, negative=negative, desc_full=None)
def _parse_chat_excerpt(excerpt: str) -> list[dict]:
messages: list[dict] = []
for line in (excerpt or "").splitlines():
line = line.strip()
if not line:
continue
lower = line.lower()
if lower.startswith("user:"):
messages.append({"role": "user", "content": line[5:].strip()})
elif lower.startswith("assistant:"):
messages.append({"role": "assistant", "content": line[10:].strip()})
elif lower.startswith("system:"):
messages.append({"role": "system", "content": line[7:].strip()})
else:
messages.append({"role": "user", "content": line})
return messages
async def run_prompt_builder(
persona_id: str,
*,
messages: list[dict] | None = None,
chat_excerpt: str = "",
outfit_json: str = "[]",
appearance_override: str | None = None,
use_prose: bool = False,
) -> dict:
"""Debug: full SD prompt builder pipeline with LLM raw output."""
persona = await get_persona(persona_id) or {}
if appearance_override is not None:
persona = {**persona, "appearance_tags": appearance_override}
recent = messages if messages is not None else _parse_chat_excerpt(chat_excerpt)
recent = [m for m in recent if m.get("role") in ("user", "assistant")]
user_block = _format_builder_user_block(persona, recent, outfit_json)
builder_messages = [
{"role": "system", "content": _builder_system()},
{"role": "user", "content": user_block},
]
model_used = SD_PROMPT_MODEL or "SYSTEM_MODEL"
result: dict = {
"persona_id": persona_id,
"sd_prompt_model": model_used,
"builder_system": _builder_system(),
"builder_user": user_block,
"anima_dual": anima_dual_enabled(),
}
raw = ""
try:
if SD_PROMPT_MODEL:
raw = await send_message_with_model(builder_messages, SD_PROMPT_MODEL)
else:
raw = await send_message(builder_messages)
result["llm_raw"] = raw
scene = _parse_scene_json(raw)
result["scene"] = scene
if not _scene_should_generate(scene):
result["skipped"] = True
result["error"] = "should_generate=false"
return result
try:
outfit_tags = ", ".join(json.loads(outfit_json or "[]"))
except Exception:
outfit_tags = ""
negative = _negative_for_scene(scene)
if _is_anima():
tags_only = build_positive_prompt_tags_only(scene, persona, outfit_tags)
hybrid = build_positive_prompt_hybrid(scene, persona, outfit_tags)
result["tag_positive"] = tags_only
result["hybrid_positive"] = hybrid
result["negative"] = negative
result["tags_only_full"] = tags_only + NEGATIVE_PROMPT_SEPARATOR + negative
result["hybrid_full"] = hybrid + NEGATIVE_PROMPT_SEPARATOR + negative
result["tag_full"] = result["hybrid_full"]
else:
positive = build_positive_prompt(scene, persona, outfit_tags)
result["tag_positive"] = positive
result["negative"] = negative
result["tag_full"] = positive + NEGATIVE_PROMPT_SEPARATOR + negative
except Exception as e:
result["error"] = str(e)
result["llm_raw"] = raw or result.get("llm_raw", "")
return result
async def generate_sd_prompt(
messages: list,
persona_id: str,
outfit_json: str = "[]",
) -> SdPromptBundle | None:
persona = await get_persona(persona_id)
if not persona:
return None
recent = [m for m in messages if m["role"] in ("user", "assistant")]
if not recent:
return None
user_block = _format_builder_user_block(persona, recent, outfit_json)
builder_messages = [
{"role": "system", "content": _builder_system()},
{"role": "user", "content": user_block},
]
raw = ""
try:
if SD_PROMPT_MODEL:
raw = await send_message_with_model(builder_messages, SD_PROMPT_MODEL)
else:
raw = await send_message(builder_messages)
scene = _parse_scene_json(raw)
except Exception as e:
logger.warning("sd_prompt failed: %s raw=%.200s", e, raw)
return None
if not _scene_should_generate(scene):
logger.info("sd_prompt: skipped (should_generate=false)")
return None
try:
outfit_list = json.loads(outfit_json or "[]")
outfit_tags = ", ".join(outfit_list) if isinstance(outfit_list, list) else ""
except Exception:
outfit_tags = ""
bundle = _bundle_from_scene(scene, persona, outfit_tags)
if anima_dual_enabled() and bundle.desc_full:
logger.info(
"Anima prompts: hybrid=%.80s | tags_only=%.80s",
bundle.tag_full.split(NEGATIVE_PROMPT_SEPARATOR)[0],
bundle.desc_full.split(NEGATIVE_PROMPT_SEPARATOR)[0],
)
return bundle
+322 -23
View File
@@ -3,6 +3,7 @@ import logging
import os
import uuid
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
import httpx
from dotenv import load_dotenv
@@ -11,7 +12,178 @@ load_dotenv()
logger = logging.getLogger(__name__)
SD_BASE_URL = os.getenv("SD_BASE_URL", "http://127.0.0.1:8188").rstrip("/")
def _parse_basic_auth() -> httpx.BasicAuth | None:
"""
Vast Caddy on mapped ports often uses Basic realm=restricted.
Set SD_COMFY_HTTP_BASIC=user:password or SD_COMFY_USER + SD_COMFY_PASSWORD.
"""
raw = (os.getenv("SD_COMFY_HTTP_BASIC") or "").strip()
if raw:
if ":" in raw:
user, _, password = raw.partition(":")
else:
user, password = "", raw
return httpx.BasicAuth(user, password)
user = (os.getenv("SD_COMFY_USER") or "").strip()
password = (os.getenv("SD_COMFY_PASSWORD") or "").strip()
if user or password:
return httpx.BasicAuth(user, password)
return None
SD_BASIC_AUTH = _parse_basic_auth()
def _parse_comfy_config() -> tuple[str, dict[str, str]]:
"""
SD_BASE_URL may be pasted from Vast/Comfy UI with ?token=...
API paths must be base + /prompt, not ...?token=xxx/prompt
"""
raw = (os.getenv("SD_BASE_URL") or "http://127.0.0.1:8188").strip()
extra_token = (os.getenv("SD_COMFY_TOKEN") or "").strip()
parsed = urlparse(raw)
base = f"{parsed.scheme}://{parsed.netloc}"
path = (parsed.path or "").rstrip("/")
if path and path != "/":
base = f"{base}{path}"
query: dict[str, str] = {}
for key, values in parse_qs(parsed.query).items():
if values:
query[key] = values[-1]
if extra_token:
query["token"] = extra_token
base = base.rstrip("/")
# Cloudflare tunnel to localhost:8188 — direct Comfy API, Vast ?token= does not apply
if "trycloudflare.com" in base.lower():
if query.pop("token", None):
logger.info(
"SD_BASE_URL is trycloudflare tunnel: Vast token stripped. "
"Use tunnel for port 8188 only (see instance Port Mapping)."
)
return base, query
SD_BASE_URL, SD_QUERY_PARAMS = _parse_comfy_config()
def _comfy_url(path: str) -> str:
if not path.startswith("/"):
path = f"/{path}"
return f"{SD_BASE_URL}{path}"
def _log_comfy_target() -> str:
if SD_QUERY_PARAMS.get("token"):
return f"{SD_BASE_URL}?token=***"
return SD_BASE_URL
def _absolute_url(location: str, fallback_path: str = "/") -> str:
if not location:
return _comfy_url(fallback_path)
if location.startswith(("http://", "https://")):
return location
if location.startswith("/"):
return f"{SD_BASE_URL}{location}"
return f"{SD_BASE_URL}/{location}"
def _url_with_token(url: str) -> str:
"""Append gateway token to URL (Vast/Cloudflare often strip ?token on redirect)."""
if not SD_QUERY_PARAMS.get("token"):
return url
p = urlparse(url)
q: dict[str, str] = {}
for key, values in parse_qs(p.query).items():
if values:
q[key] = values[-1]
q.update(SD_QUERY_PARAMS)
return urlunparse((p.scheme, p.netloc, p.path, "", urlencode(q), ""))
def _merge_params(extra: dict | None) -> dict | None:
if not SD_QUERY_PARAMS and not extra:
return None
merged = dict(SD_QUERY_PARAMS)
if extra:
merged.update(extra)
return merged
def _is_vast_gateway() -> bool:
return "trycloudflare.com" not in SD_BASE_URL.lower()
def _make_comfy_client(*, timeout: float = 300) -> httpx.AsyncClient:
return httpx.AsyncClient(
timeout=timeout,
follow_redirects=False,
auth=SD_BASIC_AUTH,
)
async def _prime_comfy_gateway(client: httpx.AsyncClient) -> None:
"""
Vast Caddy: browser opens /?token=… and gets a session cookie; API then works.
Prime with redirects so Set-Cookie is collected, then merge into the API client.
"""
token = SD_QUERY_PARAMS.get("token")
if not token or not _is_vast_gateway():
return
try:
async with httpx.AsyncClient(
timeout=30,
follow_redirects=True,
auth=SD_BASIC_AUTH,
) as prime:
r = await prime.get(_comfy_url("/"), params={"token": token})
client.cookies.update(prime.cookies)
logger.info(
"Comfy gateway prime GET /?token=*** → %s, cookies=%s",
r.status_code,
list(prime.cookies.keys()) or "(none)",
)
except Exception as e:
logger.warning("Comfy gateway prime failed: %s", e)
async def _comfy_request(
client: httpx.AsyncClient,
method: str,
path: str,
*,
params: dict | None = None,
**kwargs,
) -> httpx.Response:
"""
Comfy API: trycloudflare tunnel = no token.
Vast IP:PORT gateway = ?token= + cookie prime; follow redirects with token re-attached.
"""
url = _comfy_url(path)
extra = params or {}
token = SD_QUERY_PARAMS.get("token")
use_vast_auth = _is_vast_gateway() and (bool(token) or SD_BASIC_AUTH is not None)
if token and _is_vast_gateway():
await _prime_comfy_gateway(client)
req_params: dict | None = _merge_params(extra) if use_vast_auth else (extra or None)
resp: httpx.Response | None = None
for hop in range(6):
resp = await client.request(method, url, params=req_params, **kwargs)
if resp.status_code not in (301, 302, 303, 307, 308):
return resp
loc = _absolute_url(resp.headers.get("location", ""), path)
url = _url_with_token(loc) if use_vast_auth else loc
req_params = extra or None
logger.info("Comfy redirect %s hop %s%s", resp.status_code, hop + 1, url.split("?")[0])
assert resp is not None
return resp
SD_STEPS = int(os.getenv("SD_STEPS", "28"))
SD_CFG = float(os.getenv("SD_CFG", "7"))
SD_SAMPLER = os.getenv("SD_SAMPLER", "euler")
@@ -26,6 +198,8 @@ SD_DEFAULT_NEGATIVE = os.getenv(
SD_UNET = os.getenv("SD_UNET", "anima-preview3-base.safetensors")
SD_CLIP = os.getenv("SD_CLIP", "qwen_3_06b_base.safetensors")
SD_VAE = os.getenv("SD_VAE", "qwen_image_vae.safetensors")
SD_STYLE_LORA = os.getenv("SD_STYLE_LORA", "")
SD_STYLE_LORA_WEIGHT = float(os.getenv("SD_STYLE_LORA_WEIGHT", "0.7"))
IMAGES_DIR = Path(os.getenv("IMAGES_DIR", "static/images"))
@@ -38,19 +212,37 @@ def _use_anima() -> bool:
def split_prompt_and_negative(full_prompt: str) -> tuple[str, str]:
# Try new separator first
sep = "__NEGATIVE_PROMPT__"
if f"\n{sep}\n" in full_prompt:
pos, _, neg = full_prompt.partition(f"\n{sep}\n")
return pos.strip(), neg.strip()
# Fallback to old format
if "\n\nNegative prompt:" in full_prompt:
pos, _, neg = full_prompt.partition("\n\nNegative prompt:")
return pos.strip(), neg.strip()
return full_prompt.strip(), SD_DEFAULT_NEGATIVE
def _build_workflow(positive: str, negative: str) -> dict:
def _workflow_uses_anima(overrides: dict | None) -> bool:
if overrides and overrides.get("checkpoint"):
return False
if overrides and overrides.get("unet"):
return True
return _use_anima()
def _build_workflow(positive: str, negative: str, overrides: dict | None = None) -> dict:
seed = int(uuid.uuid4().int % 2**32)
if _use_anima():
return {
"44": {"class_type": "UNETLoader", "inputs": {"unet_name": SD_UNET, "weight_dtype": "default"}},
"45": {"class_type": "CLIPLoader", "inputs": {"clip_name": SD_CLIP, "type": "stable_diffusion", "device": "default"}},
"15": {"class_type": "VAELoader", "inputs": {"vae_name": SD_VAE}},
o = overrides or {}
if _workflow_uses_anima(o):
unet = o.get("unet") or SD_UNET
clip = o.get("clip") or SD_CLIP
vae = o.get("vae") or SD_VAE
workflow = {
"44": {"class_type": "UNETLoader", "inputs": {"unet_name": unet, "weight_dtype": "default"}},
"45": {"class_type": "CLIPLoader", "inputs": {"clip_name": clip, "type": "stable_diffusion", "device": "default"}},
"15": {"class_type": "VAELoader", "inputs": {"vae_name": vae}},
"28": {"class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}},
"11": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["45", 0]}},
"12": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["45", 0]}},
@@ -68,9 +260,24 @@ def _build_workflow(positive: str, negative: str) -> dict:
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["19", 0], "vae": ["15", 0]}},
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "chatbot", "images": ["8", 0]}},
}
# Standard checkpoint workflow (Pony / SDXL)
if SD_STYLE_LORA:
workflow["46"] = {
"class_type": "LoraLoader",
"inputs": {
"lora_name": SD_STYLE_LORA,
"model": ["44", 0],
"clip": ["45", 0],
"strength_model": SD_STYLE_LORA_WEIGHT,
"strength_clip": SD_STYLE_LORA_WEIGHT,
},
}
workflow["19"]["inputs"]["model"] = ["46", 0]
workflow["11"]["inputs"]["clip"] = ["46", 1]
workflow["12"]["inputs"]["clip"] = ["46", 1]
return workflow
ckpt = o.get("checkpoint") or SD_CHECKPOINT
return {
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": SD_CHECKPOINT}},
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": ckpt}},
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 832, "height": 1216, "batch_size": 1}},
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["4", 1]}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["4", 1]}},
@@ -89,24 +296,78 @@ def _build_workflow(positive: str, negative: str) -> dict:
}
async def comfy_api_request(
method: str,
path: str,
*,
params: dict | None = None,
json_body: dict | None = None,
timeout: float = 60,
) -> tuple[int, dict | str, dict]:
"""
Raw Comfy API call for debug. Returns (status_code, parsed_json_or_text, response_headers_subset).
"""
async with _make_comfy_client(timeout=timeout) as client:
await _prime_comfy_gateway(client)
token = SD_QUERY_PARAMS.get("token")
use_vast = _is_vast_gateway() and (bool(token) or SD_BASIC_AUTH is not None)
req_params = _merge_params(params) if use_vast else (params or None)
req_kwargs: dict = {}
if json_body is not None and method.upper() not in ("GET", "HEAD"):
req_kwargs["json"] = json_body
resp = await _comfy_request(
client,
method.upper(),
path,
params=req_params,
**req_kwargs,
)
headers = {
k: resp.headers.get(k)
for k in ("content-type", "location", "www-authenticate")
if resp.headers.get(k)
}
try:
body = resp.json()
except Exception:
body = resp.text[:8000]
return resp.status_code, body, headers
async def fetch_object_info() -> dict:
status, body, _ = await comfy_api_request("GET", "/object_info", timeout=120)
if status != 200 or not isinstance(body, dict):
raise RuntimeError(f"object_info failed: HTTP {status} {body!s:.300}")
return body
async def check_sd() -> bool:
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(f"{SD_BASE_URL}/system_stats")
async with _make_comfy_client(timeout=15) as client:
await _prime_comfy_gateway(client)
r = await _comfy_request(client, "GET", "/system_stats")
return r.status_code == 200
except Exception:
return False
async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[bytes, str]:
async def txt2img(
prompt: str,
negative_prompt: str | None = None,
*,
overrides: dict | None = None,
) -> tuple[bytes, str]:
neg = negative_prompt or SD_DEFAULT_NEGATIVE
workflow = _build_workflow(prompt, neg)
workflow = _build_workflow(prompt, neg, overrides)
client_id = uuid.uuid4().hex
logger.info("ComfyUI request → %s prompt: %.120s", SD_BASE_URL, prompt)
async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(
f"{SD_BASE_URL}/prompt",
logger.info("ComfyUI request → %s prompt: %.120s", _log_comfy_target(), prompt)
async with _make_comfy_client() as client:
await _prime_comfy_gateway(client)
resp = await _comfy_request(
client,
"POST",
"/prompt",
json={"prompt": workflow, "client_id": client_id},
)
resp.raise_for_status()
@@ -115,7 +376,7 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte
for _ in range(300):
await asyncio.sleep(1)
hist = await client.get(f"{SD_BASE_URL}/history/{prompt_id}")
hist = await _comfy_request(client, "GET", f"/history/{prompt_id}")
data = hist.json()
if prompt_id in data:
entry = data[prompt_id]
@@ -127,9 +388,15 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte
for node_output in outputs.values():
if "images" in node_output:
img_info = node_output["images"][0]
img_resp = await client.get(
f"{SD_BASE_URL}/view",
params={"filename": img_info["filename"], "subfolder": img_info.get("subfolder", ""), "type": img_info.get("type", "output")},
img_resp = await _comfy_request(
client,
"GET",
"/view",
params={
"filename": img_info["filename"],
"subfolder": img_info.get("subfolder", ""),
"type": img_info.get("type", "output"),
},
)
img_resp.raise_for_status()
image_bytes = img_resp.content
@@ -145,11 +412,43 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte
raise RuntimeError("ComfyUI generation timed out or produced no output")
async def generate_from_full_prompt(full_prompt: str) -> tuple[str | None, str | None]:
async def generate_from_full_prompt(
full_prompt: str,
*,
overrides: dict | None = None,
) -> tuple[str | None, str | None]:
positive, negative = split_prompt_and_negative(full_prompt)
try:
_, rel_path = await txt2img(positive, negative)
_, rel_path = await txt2img(positive, negative, overrides=overrides)
return rel_path, None
except httpx.HTTPStatusError as e:
code = e.response.status_code
if code == 401:
logger.error(
"ComfyUI 401: Vast Caddy needs SD_COMFY_TOKEN (or ?token= in SD_BASE_URL) "
"and/or SD_COMFY_HTTP_BASIC=user:pass from the instance page. "
"Test: curl -u user:pass http://IP:PORT/system_stats "
"or open /?token=… in browser then curl with cookies. "
"Alternative: trycloudflare URL for localhost:8188 in Port Mapping."
)
elif code in (301, 302, 303, 307, 308):
logger.error(
"ComfyUI %s: wrong URL — use trycloudflare tunnel for 8188, not web UI link. "
"SD_BASE_URL=https://reviewer-relief-edmonton-specializing.trycloudflare.com "
"(no ?token=). Location: %s",
code,
e.response.headers.get("location"),
)
else:
logger.error("ComfyUI HTTP %s: %s", code, e)
return None, str(e)
except httpx.ConnectError as e:
logger.error(
"ComfyUI connect failed (%s): IP:8188 is often not exposed on Vast. "
"Use trycloudflare URL from Port Mapping for localhost:8188.",
e,
)
return None, str(e)
except Exception as e:
logger.error("ComfyUI error: %s", e)
return None, str(e)
+31
View File
@@ -0,0 +1,31 @@
import logging
from services.memory import get_session
logger = logging.getLogger(__name__)
async def resolve_session_persona(
session_id: str,
requested: str | None = None,
*,
create_persona: str | None = None,
) -> str:
"""
Session.persona_id is the source of truth.
requested is ignored when it disagrees (logged). create_persona used only if session missing.
"""
session = await get_session(session_id)
if not session:
return (create_persona or requested or "default").strip() or "default"
bound = (session.get("persona_id") or "default").strip() or "default"
req = (requested or "").strip()
if req and req != bound:
logger.warning(
"persona_id mismatch session=%s bound=%s requested=%s (using bound)",
session_id,
bound,
req,
)
return bound
+21
View File
@@ -0,0 +1,21 @@
import logging
from services.chat_prompt import get_system_prompt
from services.memory import get_all_sessions, get_history, upsert_static_system_message
logger = logging.getLogger(__name__)
async def migrate_static_system_messages() -> int:
"""Rebuild stored system rows from sessions.persona_id (strip legacy RPG text)."""
updated = 0
for session in await get_all_sessions():
sid = session["session_id"]
persona_id = session.get("persona_id") or "default"
history = await get_history(sid)
static = await get_system_prompt(persona_id, history, "")
if await upsert_static_system_message(sid, static, history):
updated += 1
if updated:
logger.info("Migrated %s session system message(s) to static persona prompt", updated)
return updated
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

+15 -1
View File
@@ -283,7 +283,16 @@ header h1 { font-size: 1.1rem; color: #e94560; }
.translate-btn:hover { background: #4a90d9; color: white; }
.translate-btn:disabled { opacity: 0.5; cursor: default; }
.chat-image { margin-top: 8px; max-width: 100%; border-radius: 8px; border: 1px solid #0f3460; }
.chat-image-wrap { margin-top: 8px; }
.chat-image-label {
font-size: 0.75rem;
color: #888;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.chat-image { max-width: 100%; border-radius: 8px; border: 1px solid #0f3460; display: block; }
.image-prompt-blocks .image-prompt-block + .image-prompt-block { margin-top: 8px; }
.image-generating {
display: flex;
@@ -587,6 +596,11 @@ textarea:focus { border-color: #e94560; }
flex-direction: row !important;
padding: 8px 0;
}
.hint-text {
font-size: 0.8rem;
color: #888;
margin: 0 0 8px;
}
.chat-settings-meta {
margin-top: 12px; padding: 10px;
background: #1a1a2e; border-radius: 8px;
+209
View File
@@ -0,0 +1,209 @@
/* app.css sets body { overflow: hidden; height: 100vh } for chat layout */
html:has(body.debug-page),
body.debug-page {
height: auto;
min-height: 100vh;
overflow-x: hidden;
overflow-y: auto;
}
.debug-page {
background: #0f0f1a;
color: #ddd;
min-height: 100vh;
padding-bottom: 48px;
}
.debug-header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 20px;
border-bottom: 1px solid #1a2744;
background: #16213e;
}
.debug-header a {
color: #9b7fd4;
text-decoration: none;
}
.debug-header h1 {
flex: 1;
margin: 0;
font-size: 1.1rem;
}
.debug-tabs {
display: flex;
gap: 4px;
padding: 8px 16px;
background: #12121f;
border-bottom: 1px solid #1a2744;
flex-wrap: wrap;
}
.debug-tabs button {
background: transparent;
border: 1px solid #2a3a5c;
color: #aaa;
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
}
.debug-tabs button.active {
background: #1a2744;
color: #e94560;
border-color: #e94560;
}
.debug-main {
padding: 16px 20px 40px;
max-width: 1200px;
margin: 0 auto;
overflow: visible;
}
.debug-panel {
display: none;
}
.debug-panel.active {
display: block;
}
.debug-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.debug-grid label,
.debug-main > label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.85rem;
color: #aaa;
margin-bottom: 10px;
}
.debug-grid input,
.debug-grid select,
.debug-main textarea,
.debug-main input,
.debug-main select {
background: #1a1a2e;
border: 1px solid #0f3460;
color: #eee;
border-radius: 6px;
padding: 8px;
font-family: inherit;
}
.debug-main textarea {
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.debug-btn {
background: #1a2744;
border: 1px solid #3a5080;
color: #ccc;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 12px;
}
.debug-btn.primary {
background: #e94560;
border-color: #e94560;
color: #fff;
}
.debug-btn:hover {
filter: brightness(1.1);
}
.debug-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.debug-out {
background: #0a0a14;
border: 1px solid #1a2744;
border-radius: 8px;
padding: 12px;
overflow: auto;
max-height: 420px;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-word;
}
.debug-out.compact {
max-height: 160px;
}
.debug-out.small {
max-height: 240px;
}
.debug-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 800px) {
.debug-split {
grid-template-columns: 1fr;
}
}
.debug-split h3,
.debug-main h3 {
font-size: 0.9rem;
color: #9b7fd4;
margin: 16px 0 8px;
}
.debug-img-wrap {
margin: 12px 0;
}
.debug-img-wrap img {
max-width: 100%;
max-height: 512px;
border-radius: 8px;
border: 1px solid #333;
}
.debug-img-wrap.hidden {
display: none;
}
.model-list-block {
margin-bottom: 8px;
}
.model-list-block summary {
cursor: pointer;
color: #9b7fd4;
}
.model-list-block ul {
margin: 4px 0 0;
padding-left: 1.2rem;
font-size: 0.8rem;
max-height: 120px;
overflow: auto;
}
+134
View File
@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug — AI ChatBot</title>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/debug.css">
</head>
<body class="debug-page">
<header class="debug-header">
<a href="/">← Чат</a>
<h1>Debug</h1>
<button type="button" id="btnReloadConfig">↻ Config</button>
</header>
<nav class="debug-tabs" id="debugTabs">
<button type="button" class="active" data-tab="config">Config</button>
<button type="button" data-tab="sdprompt">SD Prompt</button>
<button type="button" data-tab="llm">LLM</button>
<button type="button" data-tab="comfy">ComfyUI</button>
</nav>
<main class="debug-main">
<section class="debug-panel active" id="panel-config">
<pre id="configOut" class="debug-out">Загрузка…</pre>
</section>
<section class="debug-panel" id="panel-sdprompt">
<div class="debug-grid">
<label>Персонаж
<select id="sdPersona"></select>
</label>
<label>Outfit JSON
<input type="text" id="sdOutfit" value="[]" placeholder='["dress", "barefoot"]'>
</label>
<label>Appearance override (опц.)
<input type="text" id="sdAppearance" placeholder="оставьте пустым — из карточки">
</label>
<label>
<input type="checkbox" id="sdUseProse"> Использовать prose для Anima
</label>
</div>
<label>Чат (user: / assistant:)
<textarea id="sdChat" rows="8" placeholder="user: привет&#10;assistant: *улыбается*"></textarea>
</label>
<button type="button" class="debug-btn primary" id="btnSdPrompt">Собрать промпт (SD_PROMPT_MODEL)</button>
<div class="debug-split">
<div>
<h3>Scene JSON</h3>
<pre id="sdScene" class="debug-out"></pre>
</div>
<div>
<h3>Теги / гибрид</h3>
<pre id="sdPrompts" class="debug-out"></pre>
</div>
</div>
<details>
<summary>LLM raw + builder</summary>
<pre id="sdLlmRaw" class="debug-out small"></pre>
</details>
</section>
<section class="debug-panel" id="panel-llm">
<div class="debug-grid">
<label>Model
<input type="text" id="llmModel" placeholder="пусто = SD_PROMPT_MODEL / SYSTEM">
</label>
</div>
<label>System
<textarea id="llmSystem" rows="4"></textarea>
</label>
<label>User
<textarea id="llmUser" rows="6"></textarea>
</label>
<button type="button" class="debug-btn primary" id="btnLlm">Отправить</button>
<pre id="llmOut" class="debug-out"></pre>
</section>
<section class="debug-panel" id="panel-comfy">
<div class="debug-row">
<button type="button" class="debug-btn" id="btnComfyPing">Ping /system_stats</button>
<button type="button" class="debug-btn" id="btnComfyModels">Загрузить модели (/object_info)</button>
</div>
<pre id="comfyPingOut" class="debug-out compact"></pre>
<h3>Модели в Comfy</h3>
<div class="debug-grid" id="comfyModelLists"></div>
<h3>Генерация</h3>
<div class="debug-grid">
<label>UNET <select id="genUnet"><option value="">— env —</option></select></label>
<label>CLIP <select id="genClip"><option value="">— env —</option></select></label>
<label>VAE <select id="genVae"><option value="">— env —</option></select></label>
<label>Checkpoint <select id="genCkpt"><option value="">— env / Anima —</option></select></label>
</div>
<label>Positive
<textarea id="genPositive" rows="4"></textarea>
</label>
<label>Negative
<textarea id="genNegative" rows="2"></textarea>
</label>
<button type="button" class="debug-btn primary" id="btnComfyGen">Сгенерировать</button>
<div id="comfyImgWrap" class="debug-img-wrap hidden">
<img id="comfyImg" alt="result">
</div>
<pre id="comfyGenOut" class="debug-out compact"></pre>
<h3>Raw API</h3>
<div class="debug-grid">
<label>Method
<select id="rawMethod">
<option>GET</option>
<option>POST</option>
</select>
</label>
<label>Path
<input type="text" id="rawPath" value="/system_stats">
</label>
</div>
<label>Query JSON
<textarea id="rawParams" rows="2">{}</textarea>
</label>
<label>Body JSON (POST)
<textarea id="rawBody" rows="4" placeholder="{}"></textarea>
</label>
<button type="button" class="debug-btn" id="btnComfyRaw">Выполнить</button>
<pre id="comfyRawOut" class="debug-out"></pre>
</section>
</main>
<script type="module" src="/static/js/debug.js"></script>
</body>
</html>
+5 -1
View File
@@ -13,6 +13,7 @@
<h1>🤖 AI Chat</h1>
<span class="header-title" id="headerTitle">Новый чат</span>
<span id="rpgBadge" class="rpg-badge hidden" title="RPG режим">RPG</span>
<a href="/debug" class="header-icon-btn" title="Debug" style="text-decoration:none">🛠</a>
<button id="chatSettingsBtn" type="button" class="header-icon-btn" title="Настройки чата">⚙️</button>
<span id="affinityDisplay" class="affinity-display hidden"></span>
</header>
@@ -314,6 +315,9 @@
<label>Название чата
<input type="text" id="chatSettingsTitle">
</label>
<p class="wizard-page-title">Персонаж чата</p>
<p class="hint-text">Смена персонажа перепривязывает этот чат. Историю можно сохранить или очистить.</p>
<div class="persona-pick-grid" id="chatSettingsPersonaGrid"></div>
<label class="rpg-mode-option">
<input type="checkbox" id="chatSettingsRpg"> RPG режим
</label>
@@ -346,6 +350,6 @@
</div>
</div>
<script type="module" src="/static/js/app.js?v=4"></script>
<script type="module" src="/static/js/app.js?v=9"></script>
</body>
</html>
+73 -26
View File
@@ -1,9 +1,9 @@
import { sessionId, currentPersona, dom } from './state.js';
import { parseImagePromptFromContent, copyToClipboard } from './utils.js';
import { parseImagePromptFromContent, copyToClipboard, splitSdPromptForCopy } from './utils.js';
export async function initChat(options = {}) {
if (!sessionId || !currentPersona) return;
const payload = { message: '', session_id: sessionId, persona_id: currentPersona };
if (!sessionId) return;
const payload = { message: '', session_id: sessionId };
if (options.first_mes_override?.trim()) payload.first_mes_override = options.first_mes_override.trim();
const res = await fetch('/chat/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!res.ok) return;
@@ -16,19 +16,22 @@ export function updateEmptyState() {
dom.emptyState?.classList.toggle('hidden', !!hasMessages);
}
export function createImagePromptBlock(promptText) {
function createImagePromptBlockSingle(label, promptText) {
const block = document.createElement('div');
block.className = 'image-prompt-block';
const header = document.createElement('div');
header.className = 'image-prompt-header';
header.innerHTML = '<span>🎨 SD prompt</span>';
header.innerHTML = `<span>🎨 ${label}</span>`;
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'copy-prompt-btn';
copyBtn.textContent = 'Копировать';
copyBtn.addEventListener('click', async () => {
const ok = await copyToClipboard(promptText);
copyBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const full = textEl.textContent?.trim() || promptText || '';
const ok = await copyToClipboard(splitSdPromptForCopy(full));
copyBtn.textContent = ok ? 'Скопировано' : 'Ошибка';
setTimeout(() => { copyBtn.textContent = 'Копировать'; }, 1500);
});
@@ -39,11 +42,10 @@ export function createImagePromptBlock(promptText) {
regenBtn.className = 'copy-prompt-btn';
regenBtn.textContent = '🖼 Перегенерировать';
regenBtn.addEventListener('click', async () => {
const wrapper = block.parentElement;
const wrapper = block.closest('.message');
regenBtn.disabled = true;
regenBtn.textContent = '⏳…';
wrapper?.querySelector('.chat-image')?.remove();
wrapper?.querySelector('.image-error')?.remove();
wrapper?.querySelectorAll('.chat-image-wrap, .chat-image, .image-error').forEach(el => el.remove());
showImageGenerating(wrapper);
try {
const res = await fetch('/images/generate', {
@@ -76,6 +78,26 @@ export function createImagePromptBlock(promptText) {
return block;
}
export function createImagePromptBlock(promptText, promptAlt = null) {
const wrap = document.createElement('div');
wrap.className = 'image-prompt-blocks';
wrap.appendChild(createImagePromptBlockSingle('SD prompt', promptText));
const alt = (promptAlt || '').trim();
const main = (promptText || '').trim();
if (alt && alt !== main) {
wrap.appendChild(createImagePromptBlockSingle('SD prompt (только теги)', promptAlt));
}
return wrap;
}
/** Replace or create tag + optional hybrid prompt blocks under a message. */
export function ensureImagePromptBlocks(wrapper, tagPrompt, altPrompt = null) {
if (!wrapper || !tagPrompt) return;
wrapper.querySelector('.image-prompt-blocks')?.remove();
wrapper.querySelectorAll('.image-prompt-block').forEach(el => el.remove());
wrapper.appendChild(createImagePromptBlock(tagPrompt, altPrompt || null));
}
const OUTCOME_CLASS = {
'critical failure': 'outcome-crit-fail',
'failure': 'outcome-fail',
@@ -113,7 +135,7 @@ function renderNarratorMessage(narrator) {
return el;
}
function renderChoices(wrapper, choices) {
export function renderChoices(wrapper, choices) {
if (!choices?.length) return;
const row = document.createElement('div');
row.className = 'choice-row';
@@ -169,12 +191,21 @@ export function updateAffinityDisplay(affinity) {
el.className = `affinity-display ${affinity > 5 ? 'affinity-high' : affinity < -3 ? 'affinity-low' : ''}`;
}
export function appendChatImage(wrapper, imagePath) {
export function appendChatImage(wrapper, imagePath, label = '') {
if (!imagePath) return;
const figure = document.createElement('figure');
figure.className = 'chat-image-wrap';
if (label) {
const cap = document.createElement('figcaption');
cap.className = 'chat-image-label';
cap.textContent = label;
figure.appendChild(cap);
}
const img = document.createElement('img');
img.className = 'chat-image';
img.src = imagePath;
wrapper.appendChild(img);
figure.appendChild(img);
wrapper.appendChild(figure);
}
export function showImageGenerating(wrapper) {
@@ -262,7 +293,7 @@ async function regenerateMessage(messageId, wrapper) {
const res = await fetch('/chat/regenerate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, persona_id: currentPersona, message_id: messageId }),
body: JSON.stringify({ session_id: sessionId, message_id: messageId }),
});
if (!res.ok) throw new Error('Ошибка: ' + res.status);
removeTyping();
@@ -303,6 +334,8 @@ export async function reloadChatFromServer(id) {
m.image_prompt,
m.image_path ? `/static/${m.image_path}` : null,
m.id,
m.image_prompt_alt,
m.image_path_alt ? `/static/${m.image_path_alt}` : null,
);
});
}
@@ -344,8 +377,12 @@ async function consumeStream(res) {
if (data.image_generating && bubble) {
bubble.classList.remove('typing-active');
const wrapper = bubble.parentElement;
if (data.image_prompt && !wrapper.querySelector('.image-prompt-block')) {
wrapper.appendChild(createImagePromptBlock(data.image_prompt));
if (data.image_prompt) {
ensureImagePromptBlocks(
wrapper,
data.image_prompt,
data.image_prompt_alt || null,
);
}
showImageGenerating(wrapper);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
@@ -361,14 +398,15 @@ async function consumeStream(res) {
bubble.textContent = bubble.textContent.replace(IMAGE_PROMPT_RE, '').trim();
}
if (data.image_prompt && wrapper && !wrapper.querySelector('.image-prompt-block')) {
wrapper.appendChild(createImagePromptBlock(data.image_prompt));
if (data.image_prompt && wrapper) {
ensureImagePromptBlocks(
wrapper,
data.image_prompt,
data.image_prompt_alt || null,
);
}
if (data.image_path && wrapper) {
console.log('[image] appending', data.image_path, 'to', wrapper);
appendChatImage(wrapper, data.image_path);
} else {
console.log('[image] skip: image_path=', data.image_path, 'wrapper=', wrapper);
appendChatImage(wrapper, data.image_path, '');
}
if (data.image_error && wrapper) {
const err = document.createElement('div');
@@ -388,7 +426,15 @@ async function consumeStream(res) {
}
}
export function addMessage(role, content = '', imagePrompt = null, imagePath = null, messageId = null) {
export function addMessage(
role,
content = '',
imagePrompt = null,
imagePath = null,
messageId = null,
imagePromptAlt = null,
imagePathAlt = null,
) {
updateEmptyState();
const wrapper = document.createElement('div');
wrapper.className = `message ${role}`;
@@ -446,8 +492,9 @@ export function addMessage(role, content = '', imagePrompt = null, imagePath = n
wrapper.appendChild(translateBtn);
}
if (prompt) wrapper.appendChild(createImagePromptBlock(prompt));
if (imagePath) appendChatImage(wrapper, imagePath);
if (prompt) wrapper.appendChild(createImagePromptBlock(prompt, imagePromptAlt));
if (imagePath) appendChatImage(wrapper, imagePath, imagePathAlt ? 'Теги' : '');
if (imagePathAlt) appendChatImage(wrapper, imagePathAlt, 'Гибрид');
attachMessageActions(wrapper, messageId, role);
dom.messagesEl.appendChild(wrapper);
dom.messagesEl.scrollTop = dom.messagesEl.scrollHeight;
@@ -487,7 +534,7 @@ export async function sendMessage(text, isNarratorChoice = false) {
const res = await fetch('/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, session_id: sessionId, persona_id: currentPersona, is_narrator_choice: isNarratorChoice }),
body: JSON.stringify({ message: text, session_id: sessionId, is_narrator_choice: isNarratorChoice }),
});
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
removeTyping();
+62 -3
View File
@@ -1,7 +1,10 @@
import { sessionId, currentPersona, dom } from './state.js';
import { sessionId, currentPersona, setCurrentPersona, dom } from './state.js';
import { GENRE_LABELS, bindGenreGrid, resetGenreGrid } from './utils.js';
import { personaIndex } from './personas.js';
const chatSettingsGenres = new Set();
let chatSettingsPersonaId = 'default';
let chatSettingsInitialPersonaId = 'default';
function updateChatSettingsGenresLabel() {
const el = document.getElementById('chatSettingsGenresLabel');
@@ -15,6 +18,26 @@ function updateChatSettingsGenresLabel() {
}
}
function fillChatSettingsPersonaGrid() {
const grid = document.getElementById('chatSettingsPersonaGrid');
if (!grid) return;
grid.innerHTML = '';
for (const p of personaIndex.values()) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'persona-pick-card' + (p.persona_id === chatSettingsPersonaId ? ' selected' : '');
card.dataset.id = p.persona_id;
card.innerHTML = `<span class="emoji">${p.emoji || '🤖'}</span>${p.name}`;
card.addEventListener('click', () => {
chatSettingsPersonaId = p.persona_id;
grid.querySelectorAll('.persona-pick-card').forEach(c => {
c.classList.toggle('selected', c.dataset.id === chatSettingsPersonaId);
});
});
grid.appendChild(card);
}
}
function loadRpgSettingsToDom(prefix, settings) {
document.getElementById(`${prefix}SettingDice`).checked = settings.dice !== false;
document.getElementById(`${prefix}SettingNarrator`).checked = settings.narrator !== false;
@@ -67,6 +90,10 @@ export async function openChatSettings() {
const s = await res.json();
document.getElementById('chatSettingsTitle').value = s.title || '';
chatSettingsPersonaId = s.persona_id || 'default';
chatSettingsInitialPersonaId = chatSettingsPersonaId;
fillChatSettingsPersonaGrid();
const rpgOn = !!s.rpg_enabled;
document.getElementById('chatSettingsRpg').checked = rpgOn;
document.getElementById('chatSettingsRpgBlock').classList.toggle('hidden', !rpgOn);
@@ -117,13 +144,45 @@ export function initChatSettings() {
document.getElementById('chatSettingsSave')?.addEventListener('click', async () => {
if (!sessionId) return;
const { loadSessions, applySessionUi } = await import('./sessions.js');
const { loadSessions, applySessionUi, renderSystemBlob } = await import('./sessions.js');
const { reloadChatFromServer } = await import('./chat.js');
const { highlightPersonaBar } = await import('./personas.js');
const title = document.getElementById('chatSettingsTitle').value.trim();
const rpgOn = document.getElementById('chatSettingsRpg').checked;
const genreValue = [...chatSettingsGenres].join(',') || 'adventure';
const settings = readRpgSettingsFromDom('cs');
if (chatSettingsPersonaId !== chatSettingsInitialPersonaId) {
const pName = personaIndex.get(chatSettingsPersonaId)?.name || chatSettingsPersonaId;
const keepHistory = confirm(
`Перепривязать чат к «${pName}»?\n\n`
+ 'OK — сохранить историю сообщений (персонаж в старых репликах может не совпадать).\n'
+ 'Отмена — очистить историю и начать с приветствия нового персонажа.',
);
const clearHistory = !keepHistory;
const rebindRes = await fetch(`/sessions/${sessionId}/rebind-persona`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
persona_id: chatSettingsPersonaId,
clear_history: clearHistory,
}),
});
if (!rebindRes.ok) {
const err = await rebindRes.json().catch(() => ({}));
alert(err.detail || 'Не удалось сменить персонажа');
return;
}
setCurrentPersona(chatSettingsPersonaId);
chatSettingsInitialPersonaId = chatSettingsPersonaId;
highlightPersonaBar(chatSettingsPersonaId);
await reloadChatFromServer(sessionId);
const blobRes = await fetch(`/chat/system/${sessionId}`);
if (blobRes.ok) renderSystemBlob(await blobRes.json());
}
await fetch(`/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -141,7 +200,7 @@ export function initChatSettings() {
let arc = {};
try { arc = JSON.parse(s.plot_arc_json || '{}'); } catch { /* ignore */ }
if (!arc || !Object.keys(arc).length) {
await bootstrapRpg(sessionId, currentPersona, genreValue, settings);
await bootstrapRpg(sessionId, chatSettingsPersonaId, genreValue, settings);
}
}
+217
View File
@@ -0,0 +1,217 @@
const $ = (id) => document.getElementById(id);
function fmt(obj) {
return typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
}
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = text;
}
if (!res.ok) {
const detail = data?.detail || text || res.statusText;
throw new Error(`${res.status}: ${detail}`);
}
return data;
}
function initTabs() {
const tabs = document.querySelectorAll('#debugTabs button');
tabs.forEach((btn) => {
btn.addEventListener('click', () => {
tabs.forEach((t) => t.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.debug-panel').forEach((p) => p.classList.remove('active'));
$(`panel-${btn.dataset.tab}`).classList.add('active');
});
});
}
async function loadConfig() {
const c = await api('/debug/config');
$('configOut').textContent = fmt(c);
$('llmModel').placeholder = c.sd_prompt_model || c.system_model;
return c;
}
async function loadPersonas() {
const list = await api('/debug/personas');
const sel = $('sdPersona');
sel.innerHTML = '';
for (const p of list) {
const opt = document.createElement('option');
opt.value = p.persona_id;
opt.textContent = `${p.name} (${p.persona_id})`;
sel.appendChild(opt);
}
}
async function runSdPrompt() {
$('sdScene').textContent = '…';
$('sdPrompts').textContent = '…';
const body = {
persona_id: $('sdPersona').value,
chat_excerpt: $('sdChat').value,
outfit_json: $('sdOutfit').value || '[]',
use_prose: $('sdUseProse') ? $('sdUseProse').checked : false,
};
const app = $('sdAppearance').value.trim();
if (app) body.appearance_override = app;
const data = await api('/debug/sd-prompt', { method: 'POST', body: JSON.stringify(body) });
$('sdScene').textContent = data.scene ? fmt(data.scene) : (data.error || '—');
const prompts = [];
if (data.tags_only_full) prompts.push('=== TAGS + POV (no prose) ===\n' + data.tags_only_full);
if (data.hybrid_full) prompts.push('\n=== HYBRID (Comfy) ===\n' + data.hybrid_full);
if (!data.tags_only_full && data.tag_full) prompts.push('=== PROMPT ===\n' + data.tag_full);
$('sdPrompts').textContent = prompts.join('\n') || data.error || '—';
$('sdLlmRaw').textContent = [
`model: ${data.sd_prompt_model}`,
`dual: ${data.anima_dual}`,
'',
'--- system ---',
data.builder_system || '',
'',
'--- user ---',
data.builder_user || '',
'',
'--- raw ---',
data.llm_raw || data.error || '',
].join('\n');
if (data.tag_full || data.hybrid_full) {
const src = data.hybrid_full || data.tag_full;
const parts = src.includes('__NEGATIVE_PROMPT__')
? src.split('\n\n__NEGATIVE_PROMPT__\n\n')
: src.includes('\n\nNegative prompt:')
? src.split('\n\nNegative prompt:')
: [src, ''];
$('genPositive').value = parts[0] || '';
$('genNegative').value = parts[1] || '';
}
}
async function runLlm() {
$('llmOut').textContent = '…';
const data = await api('/debug/llm', {
method: 'POST',
body: JSON.stringify({
model: $('llmModel').value.trim(),
system: $('llmSystem').value,
user: $('llmUser').value,
}),
});
$('llmOut').textContent = `model: ${data.model}\n\n${data.response}`;
}
function fillModelSelect(sel, options, configured) {
const current = sel.querySelector('option')?.value ?? '';
sel.innerHTML = `<option value="">— env: ${configured || '—'} —</option>`;
for (const name of options || []) {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
if (name === configured) opt.selected = true;
sel.appendChild(opt);
}
}
async function loadComfyModels() {
$('comfyModelLists').textContent = 'Загрузка object_info…';
const data = await api('/debug/comfy/models');
const { models, configured } = data;
fillModelSelect($('genUnet'), models.unets, configured.unet);
fillModelSelect($('genClip'), models.clips, configured.clip);
fillModelSelect($('genVae'), models.vaes, configured.vae);
fillModelSelect($('genCkpt'), models.checkpoints, configured.checkpoint);
const wrap = $('comfyModelLists');
wrap.innerHTML = '';
for (const [key, list] of Object.entries(models)) {
const block = document.createElement('details');
block.className = 'model-list-block';
block.open = key === 'unets' || key === 'checkpoints';
block.innerHTML = `<summary>${key} (${list.length})</summary>`;
const ul = document.createElement('ul');
for (const item of list) {
const li = document.createElement('li');
li.textContent = item;
ul.appendChild(li);
}
block.appendChild(ul);
wrap.appendChild(block);
}
}
async function comfyPing() {
$('comfyPingOut').textContent = '…';
const data = await api('/debug/comfy/ping');
$('comfyPingOut').textContent = fmt(data);
}
async function comfyGenerate() {
$('comfyGenOut').textContent = 'Генерация…';
$('comfyImgWrap').classList.add('hidden');
const body = {
positive: $('genPositive').value,
negative: $('genNegative').value,
};
const u = $('genUnet').value;
const c = $('genClip').value;
const v = $('genVae').value;
const ck = $('genCkpt').value;
if (u) body.unet = u;
if (c) body.clip = c;
if (v) body.vae = v;
if (ck) body.checkpoint = ck;
const data = await api('/debug/comfy/generate', {
method: 'POST',
body: JSON.stringify(body),
});
$('comfyGenOut').textContent = fmt(data);
if (data.image_path) {
$('comfyImg').src = data.image_path + '?t=' + Date.now();
$('comfyImgWrap').classList.remove('hidden');
}
}
async function comfyRaw() {
$('comfyRawOut').textContent = '…';
const data = await api('/debug/comfy/raw', {
method: 'POST',
body: JSON.stringify({
method: $('rawMethod').value,
path: $('rawPath').value,
params_json: $('rawParams').value || '{}',
body_json: $('rawBody').value || '',
}),
});
$('comfyRawOut').textContent = fmt(data);
}
function bind() {
initTabs();
$('btnReloadConfig').addEventListener('click', loadConfig);
$('btnSdPrompt').addEventListener('click', () => runSdPrompt().catch(showErr));
$('btnLlm').addEventListener('click', () => runLlm().catch(showErr));
$('btnComfyPing').addEventListener('click', () => comfyPing().catch(showErr));
$('btnComfyModels').addEventListener('click', () => loadComfyModels().catch(showErr));
$('btnComfyGen').addEventListener('click', () => comfyGenerate().catch(showErr));
$('btnComfyRaw').addEventListener('click', () => comfyRaw().catch(showErr));
}
function showErr(e) {
alert(e.message || String(e));
}
bind();
loadConfig().catch(showErr);
loadPersonas().catch(showErr);
+81 -45
View File
@@ -1,4 +1,9 @@
import { setSessionId, setCurrentPersona, currentPersona, dom } from './state.js';
import {
setSessionId,
setCurrentPersona,
getNewChatDefaultPersona,
dom,
} from './state.js';
import {
initWizard,
GENRE_LABELS,
@@ -7,9 +12,9 @@ import {
fillGreetingSelect,
getSelectedGreeting,
} from './utils.js';
import { personaIndex, highlightPersona } from './personas.js';
import { personaIndex } from './personas.js';
let newChatPersonaId = currentPersona;
let newChatPersonaId = getNewChatDefaultPersona();
let newChatGreetingCtx = null;
const newChatGenres = new Set();
const newChatModalEl = document.getElementById('newChatModal');
@@ -84,7 +89,7 @@ function fillNewChatPersonaGrid() {
const grid = document.getElementById('newChatPersonaGrid');
if (!grid) return;
grid.innerHTML = '';
newChatPersonaId = currentPersona;
newChatPersonaId = getNewChatDefaultPersona();
for (const p of personaIndex.values()) {
const card = document.createElement('button');
card.type = 'button';
@@ -121,34 +126,8 @@ function updateNewChatGenresLabel() {
}
}
async function bootstrapRpg(sid, personaId, genreValue, settings) {
const { updateQuestPanel, addMessage } = await import('./chat.js');
await fetch(`/sessions/${sid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rpg_enabled: true,
genre: genreValue,
rpg_settings_json: JSON.stringify(settings),
}),
});
const res = await fetch('/chat/rpg/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sid, persona_id: personaId, genre: genreValue }),
});
if (res.ok) {
const data = await res.json();
if (data.quests) updateQuestPanel(data.quests);
if (data.plot_arc) {
const title = data.plot_arc.title || '';
const hint = data.plot_arc.next_beat_hint || '';
if (title || hint) addMessage('assistant', `📖 ${title}${hint ? '\n' + hint : ''}`);
}
}
}
export function openNewChatWizard() {
import('./personas.js').then(({ refreshPersonaBarHighlight }) => refreshPersonaBarHighlight());
fillNewChatPersonaGrid();
resetGenreGrid(document.getElementById('newChatGenreGrid'), newChatGenres);
updateNewChatGenresLabel();
@@ -161,8 +140,17 @@ export function openNewChatWizard() {
}
export async function createNewChatFromWizard() {
const { clearMessages, initChat, reloadChatFromServer } = await import('./chat.js');
const { loadSessions, applySessionUi } = await import('./sessions.js');
const {
clearMessages,
initChat,
reloadChatFromServer,
showImageGenerating,
removeImageGenerating,
updateQuestPanel,
updateAffinityDisplay,
renderChoices,
} = await import('./chat.js');
const { loadSessions, applySessionUi, renderSystemBlob } = await import('./sessions.js');
const sid = 'sess_' + Math.random().toString(36).slice(2, 10);
setSessionId(sid);
@@ -176,10 +164,22 @@ export async function createNewChatFromWizard() {
newChatWizard?.reset();
try {
const sessionPatch = { persona_id: newChatPersonaId, rpg_enabled: rpg };
if (rpg) {
sessionPatch.genre = [...newChatGenres].join(',') || 'adventure';
sessionPatch.rpg_settings_json = JSON.stringify({
dice: document.getElementById('ncSettingDice')?.checked ?? true,
narrator: document.getElementById('ncSettingNarrator')?.checked ?? true,
quests: document.getElementById('ncSettingQuests')?.checked ?? true,
affinity: document.getElementById('ncSettingAffinity')?.checked ?? true,
choices: document.getElementById('ncSettingChoices')?.checked ?? true,
});
}
await fetch(`/sessions/${sid}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ persona_id: newChatPersonaId, rpg_enabled: rpg }),
body: JSON.stringify(sessionPatch),
});
if (customTitle) {
@@ -194,25 +194,61 @@ export async function createNewChatFromWizard() {
dom.headerTitle.textContent = rpg ? `${pName} — RPG` : `${pName} — новый чат`;
}
highlightPersona(newChatPersonaId);
const { highlightPersonaBar } = await import('./personas.js');
highlightPersonaBar(newChatPersonaId);
const greetingOverride = getNewChatFirstMesOverride();
await initChat(greetingOverride ? { first_mes_override: greetingOverride } : {});
if (rpg) {
const genreValue = [...newChatGenres].join(',') || 'adventure';
const settings = {
dice: document.getElementById('ncSettingDice')?.checked ?? true,
narrator: document.getElementById('ncSettingNarrator')?.checked ?? true,
quests: document.getElementById('ncSettingQuests')?.checked ?? true,
affinity: document.getElementById('ncSettingAffinity')?.checked ?? true,
choices: document.getElementById('ncSettingChoices')?.checked ?? true,
};
await bootstrapRpg(sid, newChatPersonaId, genreValue, settings);
const assistantWrapper = dom.messagesEl.querySelector('.message.assistant');
showImageGenerating(assistantWrapper);
let openingData = null;
try {
const openingRes = await fetch('/chat/opening/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sid,
persona_id: newChatPersonaId,
rpg,
}),
});
openingData = await openingRes.json();
if (!openingRes.ok) {
console.error('opening/process failed:', openingData.detail || openingRes.statusText);
}
} finally {
removeImageGenerating(assistantWrapper);
}
await reloadChatFromServer(sid);
if (openingData?.quests?.length) {
updateQuestPanel(openingData.quests);
}
if (openingData?.affinity !== undefined) {
updateAffinityDisplay(openingData.affinity);
}
if (openingData?.choices?.length) {
const wrapper = dom.messagesEl.querySelector('.message.assistant');
if (wrapper) renderChoices(wrapper, openingData.choices);
}
if (openingData?.image_error) {
const wrapper = dom.messagesEl.querySelector('.message.assistant');
if (wrapper) {
const err = document.createElement('div');
err.className = 'image-error';
err.textContent = '🖼 ' + openingData.image_error;
wrapper.appendChild(err);
}
}
const sessionRes = await fetch(`/sessions/${sid}`);
if (sessionRes.ok) applySessionUi(await sessionRes.json());
const blobRes = await fetch(`/chat/system/${sid}`);
if (blobRes.ok) renderSystemBlob(await blobRes.json());
await loadSessions();
} catch (e) {
console.error('createNewChat error:', e);
+18 -14
View File
@@ -1,5 +1,9 @@
import { currentPersona, setCurrentPersona, sessionId } from './state.js';
import { initChat } from './chat.js';
import {
currentPersona,
sessionId,
getNewChatDefaultPersona,
setNewChatDefaultPersona,
} from './state.js';
import { initWizard, fillGreetingSelect, getSelectedGreeting } from './utils.js';
export let personaIndex = new Map();
@@ -21,12 +25,18 @@ let cardImportWizard;
let cardPreview = null;
let cardImportFile = null;
export function highlightPersona(personaId) {
export function highlightPersonaBar(personaId) {
document.querySelectorAll('.persona-card').forEach(c => {
c.classList.toggle('active', c.dataset.id === personaId);
});
}
/** Active session → session persona; otherwise new-chat preset. */
export function refreshPersonaBarHighlight() {
const id = sessionId ? currentPersona : getNewChatDefaultPersona();
highlightPersonaBar(id);
}
export async function loadPersonas() {
const res = await fetch('/personas/');
const personas = await res.json();
@@ -37,9 +47,11 @@ export async function loadPersonas() {
const bar = document.getElementById('personaBar');
bar.innerHTML = '';
const barActiveId = sessionId ? currentPersona : getNewChatDefaultPersona();
personas.forEach(p => {
const card = document.createElement('div');
card.className = 'persona-card' + (p.persona_id === currentPersona ? ' active' : '');
card.className = 'persona-card' + (p.persona_id === barActiveId ? ' active' : '');
card.dataset.id = p.persona_id;
const isCard = p.persona_id.startsWith('card_');
const isCustomPersona = p.custom && !isCard;
@@ -131,16 +143,8 @@ export async function loadPersonas() {
}
export async function selectPersona(personaId) {
setCurrentPersona(personaId);
highlightPersona(personaId);
if (sessionId) {
await fetch(`/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ persona_id: personaId }),
});
await initChat();
}
setNewChatDefaultPersona(personaId);
highlightPersonaBar(personaId);
}
function fillImpCardForm(preview) {
+9 -4
View File
@@ -2,7 +2,7 @@ import {
sessionId, setSessionId, setCurrentPersona, currentPersona, dom, setRpgEnabled,
} from './state.js';
import { updateQuestPanel, updateAffinityDisplay } from './chat.js';
import { highlightPersona, personaIndex } from './personas.js';
import { highlightPersonaBar, personaIndex } from './personas.js';
import { formatSessionDate } from './utils.js';
import { openNewChatWizard } from './newChatWizard.js';
@@ -114,7 +114,7 @@ export async function loadChatHistory(id) {
const s = await sessionRes.json();
if (s.persona_id) {
setCurrentPersona(s.persona_id);
highlightPersona(s.persona_id);
highlightPersonaBar(s.persona_id);
}
applySessionUi(s);
}
@@ -155,7 +155,7 @@ export async function initSessions() {
let _prevBlobSections = {};
function renderSystemBlob(blob) {
export function renderSystemBlob(blob) {
const tryFmt = (str, fallback = '') => {
try { return JSON.stringify(JSON.parse(str), null, 2); } catch { return str || fallback; }
};
@@ -165,13 +165,18 @@ function renderSystemBlob(blob) {
return ` ${icon} [${q.status}] ${q.title}`;
}).join('\n');
const personaLine = blob.persona_id
? `[persona] ${blob.persona_name || blob.persona_id} (${blob.persona_id})`
: '';
const sections = {
persona: personaLine,
system_prompt: blob.system_prompt ? `[system_prompt]\n${blob.system_prompt}` : '',
status_quo: blob.status_quo ? `[status_quo]\n${blob.status_quo}` : '',
affinity: blob.affinity != null ? `[affinity] ${blob.affinity}` : '',
genre: blob.genre ? `[genre] ${blob.genre}` : '',
rpg_settings: blob.rpg_settings_json && blob.rpg_settings_json !== '{}' ? `[rpg_settings]\n${tryFmt(blob.rpg_settings_json)}` : '',
outfit: blob.outfit_json && blob.outfit_json !== '[]' ? `[outfit]\n${tryFmt(blob.outfit_json)}` : '',
outfit: `[outfit]\n${tryFmt(blob.outfit_json ?? '[]')}`,
facts: blob.facts_json && blob.facts_json !== '[]' ? `[facts]\n${tryFmt(blob.facts_json)}` : '',
plot_arc: blob.plot_arc_json && blob.plot_arc_json !== '{}' ? `[plot_arc]\n${tryFmt(blob.plot_arc_json)}` : '',
quests: questLines ? `[quests]\n${questLines}` : '',
+17 -3
View File
@@ -1,17 +1,31 @@
export let sessionId = localStorage.getItem('chat_session_id') || null;
export let currentPersona = localStorage.getItem('persona_id') || 'default';
/** Persona bound to the active session (from server, not global preset). */
export let currentPersona = 'default';
export let sidebarOpen = true;
export let rpgEnabled = false;
const NEW_CHAT_PERSONA_KEY = 'new_chat_persona_id';
export function toggleSidebar() { sidebarOpen = !sidebarOpen; return sidebarOpen; }
export function getNewChatDefaultPersona() {
return localStorage.getItem(NEW_CHAT_PERSONA_KEY)
|| localStorage.getItem('persona_id')
|| 'default';
}
export function setNewChatDefaultPersona(id) {
const pid = id || 'default';
localStorage.setItem(NEW_CHAT_PERSONA_KEY, pid);
}
export function setSessionId(id) {
sessionId = id;
if (id) localStorage.setItem('chat_session_id', id);
}
export function setCurrentPersona(id) {
currentPersona = id;
localStorage.setItem('persona_id', id);
currentPersona = id || 'default';
}
export function setRpgEnabled(v) { rpgEnabled = !!v; }
+21 -1
View File
@@ -6,12 +6,32 @@ export function parseImagePromptFromContent(content) {
return { text, prompt };
}
export function splitSdPromptForCopy(fullPrompt) {
if (!fullPrompt) return '';
const marker = '\n\nNegative prompt:';
const i = fullPrompt.indexOf(marker);
return (i >= 0 ? fullPrompt.slice(0, i) : fullPrompt).trim();
}
export async function copyToClipboard(text) {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
}
+243
View File
@@ -0,0 +1,243 @@
"""Unit tests for layered Anima prompt assembly (no LLM)."""
import os
import sys
from unittest.mock import patch
import pytest
# Ensure project root on path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from services import sd_prompt as sp
@pytest.fixture
def anima():
with patch.object(sp, "_is_anima", return_value=True), patch.object(sp, "_is_pony", return_value=False):
yield
PERSONA_WOLF = {
"appearance_tags": "wolfgirl, white_hair, golden_eyes, wolf_ears, tail, big_breast",
"appearance_prose": "",
"lora_name": "",
}
PERSONA_CARRIE = {
"appearance_tags": "short_hair, brown_hair, blue_eyes, skinny",
"appearance_prose": "",
"lora_name": "",
}
def test_walking_scene_includes_action_tags_and_contextual_pov(anima):
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "walking_together",
"viewer_body_visible": False,
"action_tags": "holding_hands, walking, smiling, looking_at_each_other",
"environment_tags": "outdoors, sunlight, golden_hour",
"scene_description": "She walks beside you, laughter in the warm afternoon light.",
})
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_WOLF, "")
assert "walking" in hybrid
assert "smiling" in hybrid
assert "holding_hands" not in hybrid
assert "looking_at_each_other" not in hybrid
assert "outdoors" in hybrid
assert "threshold" not in hybrid.lower()
assert "POV: walking beside you" in hybrid
assert "someone" not in hybrid.lower()
assert "both " not in hybrid.lower()
def test_hybrid_differs_from_tags_only_when_prose_present(anima):
scene = {
"shot_type": "first_person_pov",
"pov_cue": "walking_together",
"viewer_body_visible": False,
"action_tags": "holding_hands, walking",
"environment_tags": "outdoors, sunlight",
"scene_description": "Shared laughter drifts through the golden afternoon.",
}
tags_only = sp.build_positive_prompt_tags_only(scene, PERSONA_WOLF, "")
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_WOLF, "")
assert tags_only != hybrid
assert "Shared laughter" in hybrid
assert "Shared laughter" not in tags_only
def test_carrie_doorway_scene(anima):
scene = {
"shot_type": "first_person_pov",
"pov_cue": "doorway_invite",
"viewer_body_visible": False,
"action_tags": "arms_out, inviting_hug, smirk, looking_at_viewer",
"environment_tags": "doorway, apartment, night, indoors",
"scene_description": "She waits in the doorway with playful hunger in half-lidded eyes.",
}
outfit = "crop_top, ripped_jeans, black_jeans"
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_CARRIE, outfit)
assert "arms_out" in hybrid
assert "doorway" in hybrid
assert "crop_top" in hybrid
assert "threshold" not in hybrid.lower()
assert "POV: she blocks the doorway" in hybrid
def test_pov_inferred_from_action_when_cue_missing(anima):
scene = {
"shot_type": "first_person_pov",
"action_tags": "holding_hands, walking, smiling",
"environment_tags": "outdoors, park",
"scene_description": "",
}
tags = sp.build_positive_prompt_tags_only(scene, PERSONA_WOLF, "")
assert "POV: walking beside you" in tags
def test_negative_includes_interaction_block_for_pov_contact(anima):
scene = {
"shot_type": "first_person_pov",
"viewer_body_visible": False,
"action_tags": "arms_out, hug, inviting_hug",
"environment_tags": "doorway",
}
neg = sp._negative_for_scene(scene)
assert "duplicate" in neg
assert "extra_person" in neg
assert "third person" in neg
def test_scene_should_generate_false():
assert sp._scene_should_generate({"should_generate": False}) is False
assert sp._scene_should_generate({"should_generate": True}) is True
assert sp._scene_should_generate({}) is True
def test_format_builder_user_block_illustrate_vs_context(anima):
messages = [
{"role": "assistant", "content": "Long old first_mes " + ("x" * 900)},
{"role": "user", "content": "Hi"},
{"role": "assistant", "content": "*walks holding your hand*"},
]
block = sp._format_builder_user_block(PERSONA_WOLF, messages, "[]")
assert "=== ILLUSTRATE" in block
assert "=== Context" in block
assert "*walks holding your hand*" in block
assert "Long old first_mes" in block
assert len(block.split("Long old first_mes")[1].split("assistant:")[0]) < 900
def test_bundle_from_scene_anima_uses_hybrid_as_tag_full(anima):
scene = {
"should_generate": True,
"shot_type": "first_person_pov",
"pov_cue": "face_to_face",
"action_tags": "smiling",
"environment_tags": "indoors",
"scene_description": "A warm smile greets you.",
}
with patch.object(sp, "anima_dual_enabled", return_value=False):
bundle = sp._bundle_from_scene(scene, PERSONA_WOLF, "")
assert "A warm smile" in bundle.tag_full
assert bundle.desc_full is None
def test_user_example_walking_llm_output_cleaned(anima):
"""Regression: LLM prose/sentence leakage and second-person refs."""
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "walking_together",
"action_tags": (
"holding_hands, walking, smiling, looking_at_each_other, "
"A wolfgirl walks hand in hand with someone, both smiling and chatting"
),
"environment_tags": "outdoor, daylight, path",
"scene_description": (
"A wolfgirl walks hand in hand with someone, both smiling and chatting under the daylight."
),
})
persona = {**PERSONA_WOLF, "appearance_tags": PERSONA_WOLF["appearance_tags"] + ", pumped_up"}
tags_only = sp.build_positive_prompt_tags_only(scene, persona, "")
hybrid = sp.build_positive_prompt_hybrid(scene, persona, "")
assert "pumped_up" not in tags_only
assert "someone" not in hybrid.lower()
assert "both " not in hybrid.lower()
assert ". A wolfgirl walks" not in tags_only
assert tags_only != hybrid or not scene.get("scene_description")
def test_user_example_carrie_env_reconciled(anima):
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "doorway_invite",
"action_tags": "arms_out, inviting_hug, smirk, half-lidded_eyes",
"environment_tags": "doorway, nighttime, outdoor",
"scene_description": (
"Carrie stands in her doorway at night, arms outstretched toward you with a mischievous smirk."
),
})
hybrid = sp.build_positive_prompt_hybrid(
scene, PERSONA_CARRIE, "crop_top, ripped_jeans, black_jeans, jeans"
)
assert "outdoor" not in hybrid.lower() or "doorway" in hybrid
assert ", jeans," not in f", {hybrid},"
assert "someone" not in hybrid.lower()
def test_long_first_mes_uses_final_beat(anima):
carrie_tail = (
"About an hour later...\n\n"
"Carrie stood at her front door, arms out, smirking. "
'"Come on, hug me. Now." It\'s getting cold out.'
)
long = ("She shops for clothes.\n\n" * 5) + carrie_tail
excerpt = sp._extract_illustrate_content(long)
assert "front door" in excerpt or "hug me" in excerpt
assert "shops for clothes" not in excerpt
def test_hybrid_gets_fallback_when_no_scene_description(anima):
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "walking_together",
"action_tags": "walking, smiling",
"environment_tags": "outdoor, daylight",
"scene_description": "",
})
tags_only = sp.build_positive_prompt_tags_only(scene, PERSONA_WOLF, "")
hybrid = sp.build_positive_prompt_hybrid(scene, PERSONA_WOLF, "")
assert hybrid != tags_only
assert "afternoon" in hybrid.lower() or "laughter" in hybrid.lower()
def test_yuki_pov_drops_lifting_and_nose_rub(anima):
scene = sp._sanitize_scene_fields({
"shot_type": "first_person_pov",
"pov_cue": "face_to_face",
"action_tags": "arms_out, lifting, nose_rub, smiling",
"environment_tags": "indoors, warm_lighting",
"scene_description": "Her golden eyes soften with warmth toward the camera.",
})
hybrid = sp.build_positive_prompt_hybrid(scene, {**PERSONA_WOLF, "appearance_tags": "fox_girl, golden_eyes"}, "pink_sweater")
assert "lifting" not in hybrid
assert "nose_rub" not in hybrid
assert "golden" in hybrid.lower()
def test_bundle_tags_only_alt_when_dual_compare(anima):
scene = {
"shot_type": "first_person_pov",
"pov_cue": "dialogue_close",
"action_tags": "smiling",
"environment_tags": "indoors",
"scene_description": "Soft light on her face.",
}
with patch.object(sp, "anima_dual_enabled", return_value=True):
bundle = sp._bundle_from_scene(scene, PERSONA_WOLF, "")
assert bundle.desc_full is not None
assert bundle.desc_full != bundle.tag_full
assert "Soft light" in bundle.tag_full
assert "Soft light" not in bundle.desc_full.split(sp.NEGATIVE_PROMPT_SEPARATOR)[0]