Fixed RPG

This commit is contained in:
2026-06-01 07:44:38 +03:00
parent 600ad78f05
commit d4cd8f02f4
30 changed files with 1516 additions and 816 deletions
+91 -113
View File
@@ -1,4 +1,5 @@
import json
import logging
import os
import random
@@ -26,6 +27,8 @@ from services.memory import (
update_session_affinity,
update_session_genre,
update_session_rpg_settings,
update_session_outfit,
update_session_plot_arc,
upsert_quest,
get_quests,
add_action_resolution,
@@ -35,84 +38,57 @@ from services.memory import (
delete_message,
)
from services.personas import get_persona
from services.sd_prompt import (
generate_sd_prompt,
strip_image_prompt_tag,
extract_image_prompt_tag,
)
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.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
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
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")
def affinity_prompt_block(affinity: int) -> str:
if affinity >= 10:
tone = "very warm, trusting, affectionate"
elif affinity >= 5:
tone = "friendly and open"
elif affinity >= 1:
tone = "slightly positive"
elif affinity <= -5:
tone = "hostile or deeply distrustful"
elif affinity <= -1:
tone = "cold and wary"
else:
tone = "neutral"
return (
f"\n\n--- Relationship ---\n"
f"Affinity toward player: {affinity} ({tone}). "
f"Reflect this in your attitude and word choice.\n---"
)
DEFAULT_RPG_SETTINGS = {
"dice": True,
"narrator": True,
"quests": True,
"affinity": True,
"choices": True,
}
DEFAULT_RPG_SETTINGS = {"dice": True, "narrator": True, "quests": True, "affinity": True, "choices": True}
def get_rpg_settings(session: dict) -> dict:
try:
s = json.loads(session.get("rpg_settings_json") or "{}")
return {**DEFAULT_RPG_SETTINGS, **s}
return {**DEFAULT_RPG_SETTINGS, **json.loads(session.get("rpg_settings_json") or "{}")}
except Exception:
return DEFAULT_RPG_SETTINGS
def affinity_prompt_block(affinity: int) -> str:
if affinity >= 10: tone = "very warm, trusting, affectionate"
elif affinity >= 5: tone = "friendly and open"
elif affinity >= 1: tone = "slightly positive"
elif affinity <= -5: tone = "hostile or deeply distrustful"
elif affinity <= -1: tone = "cold and wary"
else: tone = "neutral"
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"):
recent = [m for m in history if m["role"] in ("user", "assistant")][-5:]
context = recent + [{"role": "user", "content": user_message}]
lore = get_lorebook_context(persona.get("lorebook_json", "[]"), context)
if lore:
prompt = prompt + "\n\n" + lore
prompt += "\n\n" + lore
if persona_id.startswith("card_"):
card_id = persona_id[5:]
card = await get_character(card_id)
card = await get_character(persona_id[5:])
if card:
recent = [m for m in history if m["role"] in ("user", "assistant")][-5:]
context = recent + [{"role": "user", "content": user_message}]
lore = get_lorebook_context(card.get("lorebook_json", "[]"), context)
if lore:
prompt = prompt + "\n\n" + lore
prompt += "\n\n" + lore
return prompt
@@ -126,12 +102,18 @@ 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)
quests = await get_quests(session_id)
return {
"system_prompt": system_msg.get("content") if system_msg else "",
"facts_json": session.get("facts_json") if session else "[]",
"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 "{}",
"outfit_json": session.get("outfit_json") if session else "[]",
"affinity": session.get("affinity", 0) if session else 0,
"genre": session.get("genre", "") if session else "",
"rpg_settings_json": session.get("rpg_settings_json") if session else "{}",
"rpg_enabled": bool(session.get("rpg_enabled")) if session else False,
"quests": quests,
}
@@ -147,15 +129,19 @@ async def init_chat(request: ChatRequest):
await add_message(request.session_id, "system", system_prompt)
first_mes = None
persona = await get_persona(persona_id)
if persona and persona.get("first_mes"):
first_mes = persona["first_mes"]
if request.first_mes_override and request.first_mes_override.strip():
first_mes = request.first_mes_override.strip()
await add_message(request.session_id, "assistant", first_mes)
elif persona_id.startswith("card_"):
card = await get_character(persona_id[5:])
if card and card.get("first_mes"):
first_mes = card["first_mes"]
else:
persona = await get_persona(persona_id)
if persona and persona.get("first_mes"):
first_mes = persona["first_mes"]
await add_message(request.session_id, "assistant", first_mes)
elif persona_id.startswith("card_"):
card = await get_character(persona_id[5:])
if card and card.get("first_mes"):
first_mes = card["first_mes"]
await add_message(request.session_id, "assistant", first_mes)
return {"first_mes": first_mes}
@@ -196,9 +182,9 @@ async def rpg_bootstrap(req: RpgBootstrapRequest):
# Seed quests from beats
for beat in arc.get("beats", []):
injection = beat.get("injection", "").strip()
if injection:
await upsert_quest(req.session_id, injection[:120])
title = (beat.get("title") or beat.get("injection", "")).strip()
if title:
await upsert_quest(req.session_id, title[:120])
quests = await get_quests(req.session_id)
return {"plot_arc": arc, "quests": quests}
@@ -339,11 +325,21 @@ async def chat_stream(request: ChatRequest):
async def generate():
nonlocal arc
async for chunk in stream_message(
[{"role": m["role"], "content": m["content"]} for m in messages]
):
full_reply.append(chunk)
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
# 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]
):
full_reply.append(chunk)
yield f"data: {json.dumps({'chunk': chunk})}\n\n"
except Exception as e:
logger.error("stream_message failed: %s", e)
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return
complete = "".join(full_reply)
display_text = strip_image_prompt_tag(complete)
@@ -351,17 +347,14 @@ async def chat_stream(request: ChatRequest):
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)
prompt_str = sd_result[0] if sd_result else None
if not prompt_str:
prompt_str = extract_image_prompt_tag(complete)
await add_message(
request.session_id,
"assistant",
display_text or complete,
image_prompt=prompt_str,
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)
choices = []
debug_blocks = []
@@ -370,38 +363,36 @@ async def chat_stream(request: ChatRequest):
if session and session.get("rpg_enabled"):
if not arc:
persona = await get_persona(persona_id) or {}
genre = (session.get("genre") or "adventure")
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=genre,
genre=session.get("genre") or "adventure",
)
if arc:
from services.memory import update_session_plot_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", []):
inj = beat.get("injection", "").strip()
if inj:
await upsert_quest(request.session_id, inj[:120])
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:
from services.memory import update_session_plot_arc
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):
beat_choices = beats[0].get("choices") or []
if beat_choices:
choices = choices + beat_choices
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)
@@ -409,55 +400,53 @@ async def chat_stream(request: ChatRequest):
merged = merge_facts(session.get("facts_json", "[]"), new_facts)
await update_session_facts(request.session_id, merged)
session["facts_json"] = merged
debug_blocks.append({"type": "facts", "text": facts_to_prompt(merged)})
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")
)
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", "[]")),
)
sq = (post.get("status_quo_update") or "").strip()
if sq:
await update_session_status_quo(request.session_id, sq)
session["status_quo"] = sq
debug_blocks.append({"type": "status_quo", "text": f"--- Status quo ---\n{sq}\n---"})
debug_blocks.append({"type": "status_quo", "text": sq})
if rpg_settings.get("choices", True):
extra_choices = post.get("choices") or []
if extra_choices:
choices = choices + extra_choices
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 []):
title = (qu.get("title") or "").strip()
status = qu.get("status", "active")
if title:
await upsert_quest(request.session_id, title[:120], status)
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)
count = await get_message_count(request.session_id)
if count == 2 and not request.skip_user_add:
persona = await get_persona(persona_id) or {}
persona_name = persona.get("name", persona_id)
preview = request.message[:40] + ("" if len(request.message) > 40 else "")
current = (session or {}).get("title") or "Новый чат"
if current in ("", "Новый чат"):
await update_session_title(request.session_id, f"{persona_name}{preview}")
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:
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
@@ -467,21 +456,10 @@ async def chat_stream(request: ChatRequest):
else:
image_error = err
# Fetch current affinity for UI
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,
'narrator': narrator_msg,
'affinity': affinity,
'quests': quests_updated,
})}\n\n"
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"
return StreamingResponse(
generate(),