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}