948 lines
38 KiB
Python
948 lines
38 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from models.schemas import ChatRequest, ChatResponse, MessageEditRequest, RegenerateRequest
|
|
from services.llm import LLMError, send_message, stream_message
|
|
from services.memory import (
|
|
get_history,
|
|
add_message,
|
|
clear_history,
|
|
get_or_create_session,
|
|
get_session,
|
|
update_session_title,
|
|
get_message_count,
|
|
get_last_assistant_message_id,
|
|
update_message_image,
|
|
update_session_facts,
|
|
update_session_status_quo,
|
|
update_session_genre,
|
|
update_session_plot_arc,
|
|
get_quests,
|
|
narrator_message_content,
|
|
parse_narrator_message,
|
|
add_action_resolution,
|
|
get_message,
|
|
update_message_content,
|
|
delete_messages_after,
|
|
delete_message,
|
|
delete_message_and_following,
|
|
update_message_choices,
|
|
clear_choices_for_session,
|
|
upsert_static_system_message,
|
|
save_state_snapshot,
|
|
get_last_message_id,
|
|
)
|
|
from services.context_budget import compute_payload_usage, context_warning_line
|
|
from services.rpg_state import (
|
|
apply_narrator_post,
|
|
apply_narrator_post_with_story,
|
|
parse_scene_json,
|
|
parse_stats_json,
|
|
scene_prompt_block,
|
|
affinity_prompt_block,
|
|
stats_prompt_block,
|
|
format_narrator_outcome_for_llm,
|
|
format_user_message_for_llm,
|
|
)
|
|
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.rp_sanitize import RP_OUTPUT_REMINDER, strip_ooc_from_reply
|
|
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_persist, facts_to_prompt, rp_day_from_scene
|
|
from services.rpg_context import format_narrator_context, format_arc_summary_for_runtime
|
|
from services.rpg_plot import (
|
|
generate_plot_arc,
|
|
reconcile_plot_arc,
|
|
choices_from_step,
|
|
choices_from_narrator,
|
|
)
|
|
from services.rpg_story import (
|
|
normalize_story_arc,
|
|
get_current_step,
|
|
format_step_guidance_for_character,
|
|
format_step_hint_for_character,
|
|
format_new_arc_opening,
|
|
should_show_step_injection,
|
|
mark_injection_shown,
|
|
reconcile_story_arc,
|
|
sync_quest_to_current_step,
|
|
is_arc_completed,
|
|
is_new_arc_request,
|
|
normalize_new_arc_first,
|
|
append_new_arc_roll_choice,
|
|
roll_next_arc,
|
|
step_progress,
|
|
)
|
|
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"])
|
|
|
|
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,
|
|
"stats": False,
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
def build_rpg_runtime_suffix(session: dict, rpg_settings: dict, facts_block: str = "") -> str:
|
|
runtime_suffix = ""
|
|
if facts_block:
|
|
runtime_suffix += "\n\n" + facts_block
|
|
try:
|
|
arc = json.loads(session.get("plot_arc_json") or "{}")
|
|
except Exception:
|
|
arc = {}
|
|
if arc:
|
|
summary = format_arc_summary_for_runtime(arc)
|
|
if summary:
|
|
runtime_suffix += "\n\n--- Story arc ---\n" + summary + "\n---"
|
|
status_quo = (session.get("status_quo") or "").strip()
|
|
if status_quo:
|
|
from services.rp_sanitize import status_quo_prompt_block
|
|
|
|
runtime_suffix += status_quo_prompt_block(status_quo)
|
|
scene = parse_scene_json(session.get("scene_json"))
|
|
block = scene_prompt_block(scene)
|
|
if block:
|
|
runtime_suffix += block
|
|
if rpg_settings.get("affinity", True):
|
|
runtime_suffix += affinity_prompt_block(int(session.get("affinity") or 0))
|
|
if rpg_settings.get("stats", False):
|
|
stats = parse_stats_json(session.get("narrative_stats_json"))
|
|
runtime_suffix += stats_prompt_block(stats)
|
|
return runtime_suffix
|
|
|
|
|
|
def messages_for_llm(
|
|
history: list, llm_system_content: str, *, rp_lang: str = "ru"
|
|
) -> 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
|
|
elif m["role"] == "narrator":
|
|
data = parse_narrator_message(m.get("content") or "")
|
|
if data:
|
|
out.append({
|
|
"role": "user",
|
|
"content": format_narrator_outcome_for_llm(data, lang=rp_lang),
|
|
})
|
|
elif m["role"] == "user":
|
|
has_res = bool(m.get("action_resolution"))
|
|
out.append({
|
|
"role": "user",
|
|
"content": format_user_message_for_llm(
|
|
m["content"], has_dice_resolution=has_res
|
|
),
|
|
})
|
|
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}")
|
|
async def get_chat_history(session_id: str):
|
|
return await get_history(session_id)
|
|
|
|
|
|
@router.get("/system/{session_id}")
|
|
async def get_system_blob(session_id: str):
|
|
history = await get_history(session_id)
|
|
session = await get_session(session_id)
|
|
if session and session.get("rpg_enabled"):
|
|
persona_id_pre = (session.get("persona_id") or "default")
|
|
persona_pre = await get_persona(persona_id_pre) or {}
|
|
await reconcile_plot_arc(
|
|
session_id,
|
|
persona_name=persona_pre.get("name", persona_id_pre),
|
|
recent_context=(session.get("status_quo") or "")[:2000],
|
|
genre=session.get("genre") or "adventure",
|
|
)
|
|
session = await get_session(session_id) or session
|
|
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)
|
|
rpg_settings = get_rpg_settings(session) if session else DEFAULT_RPG_SETTINGS
|
|
facts_block = facts_to_prompt(session.get("facts_json", "[]")) if session else ""
|
|
runtime_suffix = ""
|
|
if session and session.get("rpg_enabled"):
|
|
runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block)
|
|
llm_system = system_prompt + runtime_suffix
|
|
context_usage = compute_payload_usage(history, llm_system)
|
|
return {
|
|
"persona_id": persona_id,
|
|
"persona_name": persona.get("name", persona_id),
|
|
"system_prompt": system_prompt,
|
|
"status_quo": session.get("status_quo") if session else "",
|
|
"global_plot": session.get("global_plot") 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 "[]",
|
|
"scene_json": session.get("scene_json") if session else "{}",
|
|
"narrative_stats_json": session.get("narrative_stats_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,
|
|
"context_usage": context_usage,
|
|
}
|
|
|
|
|
|
@router.post("/init")
|
|
async def init_chat(request: ChatRequest):
|
|
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 upsert_static_system_message(request.session_id, system_prompt, [])
|
|
|
|
first_mes = None
|
|
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)
|
|
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}
|
|
|
|
|
|
class RpgBootstrapRequest(BaseModel):
|
|
session_id: str
|
|
persona_id: str = "default"
|
|
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)
|
|
persona_id = await resolve_session_persona(req.session_id, req.persona_id)
|
|
await update_session_genre(req.session_id, req.genre)
|
|
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)
|
|
session = await get_session(req.session_id) or {}
|
|
rpg_settings = get_rpg_settings(session)
|
|
if rpg_settings.get("narrator", True) and greeting:
|
|
from services.rpg_locale import infer_rp_language
|
|
|
|
arc_json = json.dumps(arc, ensure_ascii=False) if arc else ""
|
|
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
|
|
b_lang = infer_rp_language([{"role": "assistant", "content": greeting}])
|
|
post = await narrator_post(
|
|
persona.get("name", persona_id),
|
|
f"assistant: {greeting}",
|
|
arc_json,
|
|
facts_block,
|
|
is_opening=True,
|
|
lang=b_lang,
|
|
)
|
|
await apply_narrator_post(req.session_id, post, rpg_settings, session)
|
|
quests = await get_quests(req.session_id)
|
|
updated = await get_session(req.session_id) or {}
|
|
return {
|
|
"plot_arc": arc,
|
|
"quests": quests,
|
|
"affinity": updated.get("affinity", 0),
|
|
"scene_json": updated.get("scene_json", "{}"),
|
|
"narrative_stats_json": updated.get("narrative_stats_json", "{}"),
|
|
}
|
|
|
|
|
|
@router.post("/stream")
|
|
async def chat_stream(request: ChatRequest):
|
|
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)
|
|
static_prompt = await get_system_prompt(persona_id, history, request.message)
|
|
runtime_suffix = ""
|
|
|
|
arc = {}
|
|
roll = None
|
|
outcome = None
|
|
resolution_text = ""
|
|
narrator_msg = None # shown as narrator bubble before assistant reply
|
|
rpg_settings = {}
|
|
facts_block = ""
|
|
|
|
narrator_extra = ""
|
|
pre = {}
|
|
directives: list = []
|
|
pre_ok = False
|
|
needs_check = False
|
|
rp_lang = "ru"
|
|
story_arc_meta: dict = {}
|
|
new_arc_first = normalize_new_arc_first(request.new_arc_first)
|
|
skip_character_reply = False
|
|
new_arc_injection_text = ""
|
|
if session and session.get("rpg_enabled"):
|
|
from services.rpg_locale import infer_rp_language
|
|
|
|
rpg_settings = get_rpg_settings(session)
|
|
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
|
|
rp_lang = infer_rp_language(history)
|
|
try:
|
|
arc = json.loads(session.get("plot_arc_json") or "{}")
|
|
except Exception:
|
|
arc = {}
|
|
arc = normalize_story_arc(arc, genre=session.get("genre") or "adventure")
|
|
|
|
wants_new_arc_roll = is_arc_completed(arc) and (
|
|
new_arc_first or is_new_arc_request(request.message)
|
|
)
|
|
if wants_new_arc_roll:
|
|
if not new_arc_first:
|
|
new_arc_first = "character"
|
|
persona = await get_persona(persona_id) or {}
|
|
recent_roll = "\n".join(
|
|
f"{m['role']}: {m['content']}" for m in history[-8:]
|
|
if m.get("role") in ("user", "assistant")
|
|
)
|
|
rolled = await roll_next_arc(
|
|
request.session_id,
|
|
persona,
|
|
request.message,
|
|
session.get("genre") or "adventure",
|
|
lang=rp_lang,
|
|
recent_context=recent_roll,
|
|
facts_block=facts_block,
|
|
)
|
|
if rolled and not is_arc_completed(rolled):
|
|
arc = rolled
|
|
story_arc_meta["new_arc_rolled"] = True
|
|
story_arc_meta["new_arc_first"] = new_arc_first
|
|
skip_character_reply = new_arc_first == "user"
|
|
step0 = get_current_step(arc)
|
|
if step0:
|
|
new_arc_injection_text = format_new_arc_opening(arc, step0, lang=rp_lang)
|
|
mark_injection_shown(arc)
|
|
await update_session_plot_arc(
|
|
request.session_id, json.dumps(arc, ensure_ascii=False)
|
|
)
|
|
|
|
arc, _ = await reconcile_story_arc(
|
|
request.session_id,
|
|
persona_name=(await get_persona(persona_id) or {}).get("name", persona_id),
|
|
genre=session.get("genre") or "adventure",
|
|
)
|
|
session["plot_arc_json"] = json.dumps(arc, ensure_ascii=False)
|
|
|
|
quests_list = await get_quests(request.session_id)
|
|
narr_ctx = format_narrator_context(
|
|
arc, quests_list, session.get("status_quo") or ""
|
|
)
|
|
|
|
if rpg_settings.get("narrator", True) and not story_arc_meta.get("new_arc_rolled"):
|
|
persona = await get_persona(persona_id) or {}
|
|
recent_txt = "\n".join(
|
|
f"{m['role']}: {m['content']}" for m in history[-8:]
|
|
if m.get("role") in ("user", "assistant")
|
|
)
|
|
|
|
# Phase 1: ask narrator if check is needed (no roll yet)
|
|
pre = await narrator_pre(
|
|
persona.get("name", persona_id),
|
|
recent_txt,
|
|
json.dumps(arc, ensure_ascii=False) if arc else "",
|
|
facts_block,
|
|
request.message,
|
|
extra_context=narr_ctx,
|
|
lang=rp_lang,
|
|
)
|
|
pre_ok = bool(pre.get("_ok"))
|
|
|
|
needs_check = pre.get("needs_check", False) and rpg_settings.get("dice", True)
|
|
|
|
if needs_check:
|
|
# Phase 2: roll and get resolution
|
|
roll = random.randint(1, 20)
|
|
if roll == 1:
|
|
outcome = "critical failure"
|
|
elif roll <= 8:
|
|
outcome = "failure"
|
|
elif roll >= 20:
|
|
outcome = "critical success"
|
|
else:
|
|
outcome = "success"
|
|
|
|
pre2 = await narrator_pre(
|
|
persona.get("name", persona_id),
|
|
recent_txt,
|
|
json.dumps(arc, ensure_ascii=False) if arc else "",
|
|
facts_block,
|
|
request.message,
|
|
roll=roll,
|
|
outcome=outcome,
|
|
extra_context=narr_ctx,
|
|
lang=rp_lang,
|
|
)
|
|
resolution_text = (pre2.get("resolution_text") or "").strip()
|
|
directives = pre2.get("directives") or []
|
|
pre_sq = (pre2.get("status_quo_update") or "").strip()
|
|
else:
|
|
directives = pre.get("directives") or []
|
|
pre_sq = (pre.get("status_quo_update") or "").strip()
|
|
|
|
if directives:
|
|
narrator_extra += (
|
|
"\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
|
|
|
|
pre_for_scene = pre2 if needs_check else pre
|
|
scene_up = pre_for_scene.get("scene_update")
|
|
if isinstance(scene_up, dict) and scene_up:
|
|
from services.rpg_state import merge_scene
|
|
from services.memory import update_session_scene
|
|
|
|
merged = merge_scene(
|
|
parse_scene_json(session.get("scene_json")), scene_up
|
|
)
|
|
scene_str = json.dumps(merged, ensure_ascii=False)
|
|
await update_session_scene(request.session_id, scene_str)
|
|
session["scene_json"] = scene_str
|
|
|
|
if resolution_text:
|
|
narrator_msg = {
|
|
"roll": roll,
|
|
"outcome": outcome,
|
|
"text": resolution_text,
|
|
"original_intent": request.message,
|
|
}
|
|
|
|
if roll is not None and resolution_text:
|
|
narrator_extra += (
|
|
f"\n\n--- Mechanics (this turn) ---\n"
|
|
f"Roll d20={roll}. Outcome: {outcome}.\n"
|
|
f"Narrator resolution: {resolution_text}\n"
|
|
"The character's next reply MUST match the narrator ruling in the message history "
|
|
"(immediately after the player's intent). Do NOT re-enact the attempt as full success on failure.\n"
|
|
"---"
|
|
)
|
|
|
|
step = get_current_step(arc)
|
|
if step:
|
|
narrator_extra += format_step_guidance_for_character(step, arc, lang=rp_lang)
|
|
if (
|
|
not story_arc_meta.get("new_arc_rolled")
|
|
and should_show_step_injection(arc)
|
|
):
|
|
inj = (step.get("injection") or "").strip()
|
|
if inj:
|
|
narrator_extra += format_step_hint_for_character(inj, lang=rp_lang)
|
|
mark_injection_shown(arc)
|
|
await update_session_plot_arc(
|
|
request.session_id, json.dumps(arc, ensure_ascii=False)
|
|
)
|
|
session["plot_arc_json"] = json.dumps(arc, ensure_ascii=False)
|
|
elif story_arc_meta.get("new_arc_rolled") and new_arc_first == "character":
|
|
inj = (step.get("injection") or "").strip()
|
|
if inj:
|
|
narrator_extra += format_step_hint_for_character(inj, lang=rp_lang)
|
|
cur, total = step_progress(arc)
|
|
story_arc_meta["story_step"] = f"{cur}/{total}"
|
|
|
|
runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block) + narrator_extra
|
|
|
|
llm_system = static_prompt + runtime_suffix
|
|
if persona_id != "default" or (session and session.get("rpg_enabled")):
|
|
llm_system += RP_OUTPUT_REMINDER
|
|
|
|
user_message_content = request.message
|
|
if request.is_narrator_choice and new_arc_first:
|
|
first_who = "игрок" if new_arc_first == "user" else "персонаж"
|
|
user_message_content = (
|
|
f"[Player chose: Начать новую арку — первый ход: {first_who}]"
|
|
)
|
|
elif request.is_narrator_choice:
|
|
user_message_content = f"[Player chose: {request.message}]"
|
|
|
|
await upsert_static_system_message(request.session_id, static_prompt, history)
|
|
|
|
user_msg_id = None
|
|
if not request.skip_user_add:
|
|
await clear_choices_for_session(request.session_id)
|
|
user_msg_id = await add_message(request.session_id, "user", user_message_content)
|
|
if user_msg_id and session and session.get("rpg_enabled"):
|
|
await save_state_snapshot(request.session_id, user_msg_id)
|
|
if narrator_msg and narrator_msg.get("roll") is not None and user_msg_id:
|
|
await add_action_resolution(
|
|
request.session_id,
|
|
intent_text=request.message,
|
|
roll=narrator_msg["roll"],
|
|
outcome=narrator_msg["outcome"],
|
|
resolution_text=narrator_msg["text"],
|
|
message_id=user_msg_id,
|
|
)
|
|
narrator_msg["user_message_id"] = user_msg_id
|
|
if narrator_msg and (narrator_msg.get("text") or "").strip():
|
|
narr_id = await add_message(
|
|
request.session_id,
|
|
"narrator",
|
|
narrator_message_content(narrator_msg),
|
|
)
|
|
if narr_id and session and session.get("rpg_enabled"):
|
|
await save_state_snapshot(request.session_id, narr_id)
|
|
messages = await get_history(request.session_id)
|
|
usage = compute_payload_usage(messages, llm_system)
|
|
warn = context_warning_line(usage.get("percent", 0))
|
|
if warn:
|
|
llm_system += warn
|
|
llm_messages = messages_for_llm(messages, llm_system, rp_lang=rp_lang)
|
|
|
|
full_reply = []
|
|
|
|
async def generate():
|
|
nonlocal arc
|
|
|
|
if new_arc_injection_text:
|
|
new_arc_narrator = {"text": new_arc_injection_text}
|
|
narr_inj_id = await add_message(
|
|
request.session_id,
|
|
"narrator",
|
|
narrator_message_content(new_arc_narrator),
|
|
)
|
|
if narr_inj_id and session and session.get("rpg_enabled"):
|
|
await save_state_snapshot(request.session_id, narr_inj_id)
|
|
yield f"data: {json.dumps({'narrator': new_arc_narrator})}\n\n"
|
|
|
|
if skip_character_reply:
|
|
choices = []
|
|
step = get_current_step(arc)
|
|
if step and rpg_settings.get("choices", True):
|
|
choices += choices_from_step(step)
|
|
quests_updated = await get_quests(request.session_id)
|
|
updated_session = await get_session(request.session_id) or session
|
|
narrator_meta = {
|
|
"new_arc_rolled": True,
|
|
"new_arc_first": new_arc_first,
|
|
"story_step": story_arc_meta.get("story_step", ""),
|
|
"rp_language": rp_lang,
|
|
}
|
|
done_payload = {
|
|
"done": True,
|
|
"assistant_message_id": None,
|
|
"assistant_content": "",
|
|
"choices": choices,
|
|
"debug": [],
|
|
"affinity": updated_session.get("affinity", 0) if updated_session else 0,
|
|
"quests": quests_updated if session and session.get("rpg_enabled") else [],
|
|
"story_arc": arc if session and session.get("rpg_enabled") else None,
|
|
"narrator_meta": narrator_meta,
|
|
}
|
|
if rpg_settings.get("stats") and updated_session:
|
|
done_payload["narrative_stats"] = parse_stats_json(
|
|
updated_session.get("narrative_stats_json")
|
|
)
|
|
if session and session.get("rpg_enabled"):
|
|
last_id = await get_last_message_id(request.session_id)
|
|
if last_id:
|
|
await save_state_snapshot(request.session_id, last_id)
|
|
yield f"data: {json.dumps(done_payload)}\n\n"
|
|
return
|
|
|
|
if narrator_msg:
|
|
yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n"
|
|
|
|
try:
|
|
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:
|
|
logger.error("stream_message failed: %s", e)
|
|
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
|
return
|
|
|
|
complete = "".join(full_reply)
|
|
raw_display = strip_image_prompt_tag(complete)
|
|
display_text = strip_ooc_from_reply(raw_display)
|
|
|
|
if (display_text or raw_display).strip():
|
|
await add_message(request.session_id, "assistant", display_text or raw_display)
|
|
|
|
choices = []
|
|
debug_blocks = []
|
|
quests_updated = []
|
|
narrator_meta = {}
|
|
|
|
if session and session.get("rpg_enabled"):
|
|
try:
|
|
if not arc or not arc.get("steps"):
|
|
persona = await get_persona(persona_id) or {}
|
|
gen_ctx = "\n".join(
|
|
f"{m['role']}: {m['content']}"
|
|
for m in (await get_history(request.session_id))[-6:]
|
|
if m.get("role") in ("user", "assistant")
|
|
)
|
|
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",
|
|
lang=rp_lang,
|
|
recent_context=gen_ctx,
|
|
)
|
|
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):
|
|
await sync_quest_to_current_step(request.session_id, arc)
|
|
|
|
arc = normalize_story_arc(
|
|
arc, genre=session.get("genre") or "adventure"
|
|
)
|
|
cur, total = step_progress(arc)
|
|
narrator_meta["story_step"] = f"{cur}/{total}"
|
|
narrator_meta["rp_language"] = rp_lang
|
|
if story_arc_meta.get("new_arc_rolled"):
|
|
narrator_meta["new_arc_rolled"] = True
|
|
|
|
ctx = [
|
|
m for m in (await get_history(request.session_id))
|
|
if m["role"] in ("user", "assistant")
|
|
][-10:]
|
|
new_facts = await extract_facts(
|
|
ctx,
|
|
rp_day_hint=rp_day_from_scene(session.get("scene_json")),
|
|
existing_json=session.get("facts_json", "[]"),
|
|
)
|
|
if new_facts:
|
|
merged = await merge_facts_persist(
|
|
session.get("facts_json", "[]"),
|
|
new_facts,
|
|
rp_day_default=rp_day_from_scene(session.get("scene_json")),
|
|
scene_context=json.dumps(
|
|
parse_scene_json(session.get("scene_json")),
|
|
ensure_ascii=False,
|
|
),
|
|
status_quo=session.get("status_quo") or "",
|
|
)
|
|
await update_session_facts(request.session_id, merged)
|
|
session["facts_json"] = 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")
|
|
)
|
|
narr_ctx_post = format_narrator_context(
|
|
arc, await get_quests(request.session_id), session.get("status_quo") or ""
|
|
)
|
|
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", "[]")),
|
|
extra_context=narr_ctx_post,
|
|
lang=rp_lang,
|
|
)
|
|
|
|
sq = (post.get("status_quo_update") or "").strip()
|
|
if sq:
|
|
debug_blocks.append({"type": "status_quo", "text": sq})
|
|
|
|
if rpg_settings.get("choices", True):
|
|
choices += choices_from_narrator(post.get("choices") or [])
|
|
|
|
applied = await apply_narrator_post_with_story(
|
|
request.session_id, post, rpg_settings, session, arc=arc
|
|
)
|
|
if applied.get("arc"):
|
|
arc = applied["arc"]
|
|
narrator_meta = {
|
|
"pre_ok": pre_ok,
|
|
"post_ok": bool(post.get("_ok")),
|
|
"choices_count": len(choices),
|
|
"directives_count": len(directives),
|
|
"dice": roll is not None,
|
|
**applied,
|
|
}
|
|
|
|
if applied.get("step_advanced"):
|
|
new_step = get_current_step(arc)
|
|
if new_step:
|
|
inj = (new_step.get("injection") or "").strip()
|
|
if inj:
|
|
debug_blocks.append({"type": "narrator_injection", "text": inj})
|
|
if rpg_settings.get("choices", True):
|
|
choices += choices_from_step(new_step)
|
|
debug_blocks.append({
|
|
"type": "plot_arc",
|
|
"text": f"Step advanced: «{applied.get('new_step_title', '')}»",
|
|
})
|
|
|
|
if applied.get("arc_completed"):
|
|
debug_blocks.append({
|
|
"type": "plot_arc",
|
|
"text": "Story arc completed — new arc available",
|
|
})
|
|
|
|
if is_arc_completed(arc) and rpg_settings.get("choices", True):
|
|
choices = append_new_arc_roll_choice(choices, lang=rp_lang)
|
|
outfit_update = post.get("outfit_update")
|
|
if isinstance(outfit_update, list) and outfit_update:
|
|
from services.outfit_tags import outfit_list_to_json
|
|
|
|
session["outfit_json"] = outfit_list_to_json(outfit_update)
|
|
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:
|
|
persona = await get_persona(persona_id) or {}
|
|
preview = request.message[:40] + ("…" if len(request.message) > 40 else "")
|
|
if (session or {}).get("title", "Новый чат") in ("", "Новый чат"):
|
|
await update_session_title(request.session_id, f"{persona.get('name', persona_id)} — {preview}")
|
|
|
|
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 "[]",
|
|
scene_json=updated_session.get("scene_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)
|
|
if msg_id and choices:
|
|
await update_message_choices(
|
|
msg_id, json.dumps(choices, ensure_ascii=False)
|
|
)
|
|
|
|
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:
|
|
sd_out["image_path"] = f"/static/{rel}"
|
|
if msg_id:
|
|
await update_message_image(msg_id, rel)
|
|
else:
|
|
sd_out["image_error"] = err
|
|
sd_out["image_prompt"] = prompt_str
|
|
|
|
affinity = updated_session.get("affinity", 0) if updated_session else 0
|
|
done_payload = {
|
|
"done": True,
|
|
"assistant_message_id": msg_id,
|
|
"assistant_content": display_text or raw_display,
|
|
"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 if session and session.get("rpg_enabled") else [],
|
|
"story_arc": arc if session and session.get("rpg_enabled") else None,
|
|
"narrator_meta": narrator_meta,
|
|
}
|
|
if rpg_settings.get("stats") and updated_session:
|
|
done_payload["narrative_stats"] = parse_stats_json(
|
|
updated_session.get("narrative_stats_json")
|
|
)
|
|
|
|
if session and session.get("rpg_enabled"):
|
|
snap_id = msg_id or await get_last_message_id(request.session_id)
|
|
if snap_id:
|
|
await save_state_snapshot(request.session_id, snap_id)
|
|
|
|
yield f"data: {json.dumps(done_payload)}\n\n"
|
|
|
|
return StreamingResponse(
|
|
generate(),
|
|
media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
)
|
|
|
|
|
|
@router.post("/", response_model=ChatResponse)
|
|
async def chat(request: ChatRequest):
|
|
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)
|
|
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)
|
|
session = await get_session(request.session_id)
|
|
llm_system = static_prompt
|
|
if session and session.get("rpg_enabled"):
|
|
rpg_settings = get_rpg_settings(session)
|
|
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
|
|
llm_system += build_rpg_runtime_suffix(session, rpg_settings, facts_block)
|
|
if persona_id != "default" or (session and session.get("rpg_enabled")):
|
|
llm_system += RP_OUTPUT_REMINDER
|
|
llm_messages = messages_for_llm(messages, llm_system)
|
|
reply = await send_message(llm_messages)
|
|
display = strip_ooc_from_reply(strip_image_prompt_tag(reply))
|
|
bundle = await generate_sd_prompt(
|
|
messages,
|
|
persona_id,
|
|
outfit_json=session.get("outfit_json", "[]") if session else "[]",
|
|
scene_json=session.get("scene_json", "{}") if session else "{}",
|
|
)
|
|
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)
|
|
|
|
return ChatResponse(
|
|
reply=display,
|
|
session_id=request.session_id,
|
|
image_prompt=prompt_str,
|
|
)
|
|
|
|
|
|
@router.delete("/messages/{message_id}")
|
|
async def remove_message(message_id: int):
|
|
msg = await get_message(message_id)
|
|
if not msg:
|
|
raise HTTPException(status_code=404, detail="Сообщение не найдено")
|
|
await delete_message_and_following(msg["session_id"], message_id)
|
|
return {"status": "deleted", "message_id": message_id}
|
|
|
|
|
|
@router.patch("/messages/{message_id}")
|
|
async def edit_message(message_id: int, req: MessageEditRequest):
|
|
msg = await get_message(message_id)
|
|
if not msg:
|
|
raise HTTPException(status_code=404, detail="Сообщение не найдено")
|
|
await update_message_content(message_id, req.content)
|
|
if req.truncate_after:
|
|
await delete_messages_after(msg["session_id"], message_id)
|
|
return {"status": "updated", "message_id": message_id}
|
|
|
|
|
|
@router.post("/regenerate")
|
|
async def regenerate_chat(req: RegenerateRequest):
|
|
msg_id = req.message_id or await get_last_assistant_message_id(req.session_id)
|
|
if not msg_id:
|
|
raise HTTPException(status_code=400, detail="Нет сообщения для перегенерации")
|
|
msg = await get_message(msg_id)
|
|
if not msg or msg.get("role") != "assistant":
|
|
raise HTTPException(status_code=400, detail="Неверное сообщение")
|
|
await delete_message(msg_id)
|
|
history = await get_history(req.session_id)
|
|
last_user = next((m for m in reversed(history) if m["role"] == "user"), None)
|
|
if not last_user:
|
|
raise HTTPException(status_code=400, detail="Нет сообщения пользователя")
|
|
user_text = last_user["content"]
|
|
if user_text.startswith("[Player chose: ") and user_text.endswith("]"):
|
|
user_text = user_text[15:-1]
|
|
stream_req = ChatRequest(
|
|
message=user_text,
|
|
session_id=req.session_id,
|
|
skip_user_add=True,
|
|
)
|
|
return await chat_stream(stream_req)
|
|
|
|
|
|
@router.delete("/{session_id}")
|
|
async def clear_chat(session_id: str):
|
|
await clear_history(session_id)
|
|
return {"status": "cleared", "session_id": session_id}
|