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_affinity, update_session_genre, update_session_outfit, update_session_plot_arc, upsert_quest, get_quests, add_action_resolution, get_message, 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.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"]) 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} 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 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---" 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}") 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) 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 { "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 "{}", "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, } @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) quests = await get_quests(req.session_id) return {"plot_arc": arc, "quests": quests} @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 = "" 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: runtime_suffix += "\n\n" + facts_block try: arc = json.loads(session.get("plot_arc_json") or "{}") except Exception: arc = {} if arc: 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: runtime_suffix += "\n\n--- Status quo ---\n" + status_quo + "\n---" if rpg_settings.get("affinity", True): aff = int(session.get("affinity") or 0) runtime_suffix += affinity_prompt_block(aff) if rpg_settings.get("narrator", True): 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, ) 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, ) 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: 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 if resolution_text: await add_action_resolution( request.session_id, intent_text=request.message, roll=roll, outcome=outcome, resolution_text=resolution_text, message_id=None, ) narrator_msg = {"roll": roll, "outcome": outcome, "text": resolution_text} if roll is not None: 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" + "---" ) llm_system = static_prompt + runtime_suffix user_message_content = request.message if request.is_narrator_choice: user_message_content = f"[Player chose: {request.message}]" 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 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) display_text = strip_image_prompt_tag(complete) if (display_text or complete).strip(): await add_message(request.session_id, "assistant", display_text or complete) choices = [] debug_blocks = [] quests_updated = [] if session and session.get("rpg_enabled"): 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 {} 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) debug_blocks.append({"type": "status_quo", "text": 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(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) 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 "[]", ) 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: 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 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(), 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) llm_messages = messages_for_llm(messages, static_prompt) reply = await send_message(llm_messages) display = strip_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) return ChatResponse( reply=display, session_id=request.session_id, image_prompt=prompt_str, ) @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}