diff --git a/backend/app/api/misc.py b/backend/app/api/misc.py index a47e1d6..3064cf5 100644 --- a/backend/app/api/misc.py +++ b/backend/app/api/misc.py @@ -1,7 +1,7 @@ -"""Glossary + Triggers routes.""" +"""Glossary + Triggers + public UI settings routes.""" from __future__ import annotations -from typing import List +from typing import Any, Dict, List from uuid import UUID from sqlalchemy import select @@ -11,12 +11,37 @@ from fastapi import APIRouter, Depends, HTTPException from app.db import get_db_dep from app.deps import get_current_user -from app.models import DeferredTrigger, GlossaryEntry, Session, User, World +from app.models import DeferredTrigger, GlossaryEntry, Session, Setting, User, World from app.schemas import GlossaryEntryOut, TriggerOut router = APIRouter(prefix="/api", tags=["misc"]) +# Public, unauthenticated UI settings (logo URL etc.) — used by the frontend +# on the login/register/home pages BEFORE the user is authenticated, so the +# branding (logo, eventually theme) shows up everywhere. +# +# Only a curated subset of settings is exposed here. Anything sensitive (api +# keys, internal URLs, admin tokens) MUST stay behind /api/admin/settings. +PUBLIC_SETTING_KEYS = ("ui.logo_url",) +_PUBLIC_DEFAULTS: Dict[str, Any] = {"ui.logo_url": "/logo.png"} + + +@router.get("/settings/public") +async def get_public_settings(db: AsyncSession = Depends(get_db_dep)): + """Return UI settings that are safe to expose without authentication. + + Used by the frontend to render the logo (and other public branding) on + every page, including login/register. The response shape is a flat + `{key: value}` dict. + """ + out: Dict[str, Any] = dict(_PUBLIC_DEFAULTS) + rows = await db.execute(select(Setting).where(Setting.key.in_(PUBLIC_SETTING_KEYS))) + for row in rows.scalars().all(): + out[row.key] = row.value + return out + + @router.get("/worlds/{world_id}/glossary", response_model=List[GlossaryEntryOut]) async def list_glossary( world_id: UUID, diff --git a/backend/app/api/sessions.py b/backend/app/api/sessions.py index 8aa9789..7eaf367 100644 --- a/backend/app/api/sessions.py +++ b/backend/app/api/sessions.py @@ -14,7 +14,7 @@ from sse_starlette.sse import EventSourceResponse from app.db import get_db_dep from app.deps import get_current_user -from app.engine.orchestrator import run_iteration +from app.engine.orchestrator import generate_intro_scene, run_iteration from app.models import Message, Session, User, World from app.schemas import IterationRequest, MessageOut, SessionCreate, SessionOut @@ -142,6 +142,35 @@ async def iterate_session( return EventSourceResponse(event_generator()) +@router.post("/{session_id}/intro") +async def intro_session( + session_id: UUID, + db: AsyncSession = Depends(get_db_dep), + user: User = Depends(get_current_user), +): + """SSE stream that generates the opening cinematic scene for a new session.""" + result = await db.execute( + select(Session).join(World, Session.world_id == World.id).where(Session.id == session_id) + ) + session = result.scalars().first() + if not session: + raise HTTPException(status_code=404, detail="session_not_found") + w_result = await db.execute(select(World).where(World.id == session.world_id)) + world = w_result.scalars().first() + if not world or (world.owner_id != user.id and not user.is_admin): + raise HTTPException(status_code=403, detail="forbidden") + + async def event_generator(): + try: + async for event in generate_intro_scene(db=db, user_id=user.id, session_id=session_id): + yield {"event": event["type"], "data": json.dumps(event.get("data", {}), ensure_ascii=False, default=str)} + except Exception as e: + yield {"event": "error", "data": json.dumps({"message": str(e)}, ensure_ascii=False)} + yield {"event": "done", "data": "{}"} + + return EventSourceResponse(event_generator()) + + @router.delete("/{session_id}", status_code=204) async def delete_session( session_id: UUID, diff --git a/backend/app/api/worlds.py b/backend/app/api/worlds.py index bd869ba..976f819 100644 --- a/backend/app/api/worlds.py +++ b/backend/app/api/worlds.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException from app.db import get_db_dep from app.deps import get_current_user from app.engine.world_builder import commit_world_builder, continue_world_builder, start_world_builder +from app.engine.world_editor import edit_world_via_chat, reset_editor_dialogue from app.models import User, World from app.schemas import ( WorldBuilderCommit, @@ -19,6 +20,8 @@ from app.schemas import ( WorldBuilderReply, WorldBuilderStart, WorldCreate, + WorldEditorChatReply, + WorldEditorChatRequest, WorldOut, WorldUpdate, ) @@ -159,3 +162,56 @@ async def builder_commit( raise HTTPException(status_code=400, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=f"builder_commit_failed: {e}") + + + +# === World Editor (AI-assisted editing of an existing world) === + +@router.post("/{world_id}/chat", response_model=WorldEditorChatReply) +async def world_editor_chat( + world_id: UUID, + payload: WorldEditorChatRequest, + db: AsyncSession = Depends(get_db_dep), + user: User = Depends(get_current_user), +): + """Chat with the AI to edit an existing world's definition. + + Returns the AI's prose reply plus the proposed new definition. The + frontend must call PATCH /worlds/{id} to actually persist the change. + """ + result = await db.execute(select(World).where(World.id == world_id)) + world = result.scalars().first() + if not world: + raise HTTPException(status_code=404, detail="world_not_found") + if world.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="forbidden") + try: + ai_message, new_defn, changed = await edit_world_via_chat( + db=db, user=user, world=world, message=payload.message, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"world_editor_chat_failed: {e}") + return WorldEditorChatReply( + ai_message=ai_message, + definition=new_defn, + changed=changed, + ) + + +@router.post("/{world_id}/chat/reset") +async def world_editor_chat_reset( + world_id: UUID, + db: AsyncSession = Depends(get_db_dep), + user: User = Depends(get_current_user), +): + """Clear the cached editor dialogue for a world (start fresh).""" + result = await db.execute(select(World).where(World.id == world_id)) + world = result.scalars().first() + if not world: + raise HTTPException(status_code=404, detail="world_not_found") + if world.owner_id != user.id and not user.is_admin: + raise HTTPException(status_code=403, detail="forbidden") + reset_editor_dialogue(world_id) + return {"ok": True} diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index 074ea4b..7570ca9 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -16,6 +16,37 @@ from app.models import LlmCallLog log = get_logger("llm") +# vendor-specific end-of-sequence / control tokens that some local models +# emit into the content stream. Strip them so they don't leak to the user. +_EOS_TOKENS = ( + "", + "", + "<|endoftext|>", + "<|im_end|>", + "<|end|>", + "<|eot_id|>", + "<|eom_id|>", +) + + +def _clean_model_text(text: str) -> str: + """Remove vendor-specific end-of-sequence tokens and collapse whitespace. + + Some local models leak control tokens into the visible content stream. + We strip them so they never reach the user. + """ + if not text: + return text + cleaned = text + for tok in _EOS_TOKENS: + cleaned = cleaned.replace(tok, "") + while "\n\n\n" in cleaned: + cleaned = cleaned.replace("\n\n\n", "\n\n") + return cleaned + + + + class LlmResponse: """Non-streaming response wrapper.""" @@ -93,7 +124,7 @@ class LlmClient: data = resp.json() choice = (data.get("choices") or [{}])[0] msg = choice.get("message", {}) - text = msg.get("content") or "" + text = _clean_model_text(msg.get("content") or "") tool_calls = msg.get("tool_calls") or [] usage = data.get("usage") or {} except httpx.ConnectError as e: @@ -206,8 +237,10 @@ class LlmClient: continue delta = choices[0].get("delta", {}) if delta.get("content"): - full_text_parts.append(delta["content"]) - yield {"type": "delta", "content": delta["content"]} + piece = _clean_model_text(delta["content"]) + if piece: + full_text_parts.append(piece) + yield {"type": "delta", "content": piece} if delta.get("tool_calls"): for tc in delta["tool_calls"]: idx = tc.get("index", 0) diff --git a/backend/app/core/settings_service.py b/backend/app/core/settings_service.py index 75bcce6..665a9bb 100644 --- a/backend/app/core/settings_service.py +++ b/backend/app/core/settings_service.py @@ -34,6 +34,8 @@ EDITABLE_SETTING_KEYS = { "embedding.model": str, # e.g. text-embedding-3-small, bge-m3, nomic-embed-text "embedding.dim": int, # vector dimension; 0 = auto-probe from endpoint "embedding.request_timeout": int, # request timeout, seconds + # UI customization (logo URL/path shown in navbar + home page + favicon) + "ui.logo_url": str, # e.g. "/logo.png", "https://.../logo.png", or "data:image/png;base64,..." } diff --git a/backend/app/engine/orchestrator.py b/backend/app/engine/orchestrator.py index cdb9423..448b4b7 100644 --- a/backend/app/engine/orchestrator.py +++ b/backend/app/engine/orchestrator.py @@ -72,21 +72,39 @@ async def run_iteration( settings_map = await get_all_settings(db) llm = LlmClient(settings_map) - # Save the player's action as a message - next_seq = await _next_seq(db, session_id) - player_msg = Message( - session_id=session_id, - seq=next_seq, - role="user", - kind="player_action", - content=action_text, - payload={}, - is_pinned=True, - hidden=False, + # Save the player's action as a message — UNLESS this is a retry of the + # previous action (frontend re-sent the same action_text after an error). + # In that case we reuse the existing player_action row so the chat + # history doesn't fill up with duplicates. + last_msg_result = await db.execute( + select(Message) + .where(Message.session_id == session_id) + .order_by(Message.seq.desc()) + .limit(1) ) - db.add(player_msg) - await db.commit() - await db.refresh(player_msg) + last_msg = last_msg_result.scalars().first() + is_retry = ( + last_msg is not None + and last_msg.kind == "player_action" + and last_msg.content == action_text + ) + if is_retry: + player_msg = last_msg + else: + next_seq = await _next_seq(db, session_id) + player_msg = Message( + session_id=session_id, + seq=next_seq, + role="user", + kind="player_action", + content=action_text, + payload={}, + is_pinned=True, + hidden=False, + ) + db.add(player_msg) + await db.commit() + await db.refresh(player_msg) yield {"type": "status", "data": {"message": "planning"}} @@ -476,3 +494,122 @@ def _safe_parse_json(s: str) -> Any: return json.loads(s) if s else {} except Exception: return s + + + +async def generate_intro_scene( + db: AsyncSession, + user_id: uuid.UUID, + session_id: uuid.UUID, +) -> AsyncIterator[Dict[str, Any]]: + """Generate the opening cinematic scene for a freshly-created session. + + Yields the same SSE event stream shape as `run_iteration` so the + frontend can consume it identically. Saves a `narrative_step` message + of kind `intro_scene` (still kind=narrative_step for compatibility, + but with payload.kind=intro so the UI can style it differently if + desired). + """ + result = await db.execute(select(Session).where(Session.id == session_id)) + session = result.scalars().first() + if not session: + yield {"type": "error", "data": {"message": "session_not_found"}} + return + result = await db.execute(select(World).where(World.id == session.world_id)) + world = result.scalars().first() + if not world: + yield {"type": "error", "data": {"message": "world_not_found"}} + return + + settings_map = await get_all_settings(db) + llm = LlmClient(settings_map) + + yield {"type": "status", "data": {"message": "writing_scene"}} + + import json as _json + defn = world.definition or {} + player_state = world.state.get("player", {}) if world.state else {} + system_prompt = get_prompt("intro_scene", world.language).format( + setting_description=defn.get("setting_description", "")[:1200], + current_time=world.current_time or "", + player_state=_json.dumps(player_state, ensure_ascii=False)[:800], + plot_rails=_json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:600], + world_language=world.language or "en", + ) + + step_resp = await llm.chat( + messages=[{"role": "system", "content": system_prompt}], + tools=STEP_WRITER_TOOL_SCHEMAS, + temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))), + max_tokens=1500, + purpose="intro_scene", + user_id=user_id, + session_id=session_id, + db=db, + ) + + step_text = step_resp.text or "" + step_options: List[str] = [] + for tc in (step_resp.tool_calls or []): + if tc.get("function", {}).get("name") == "submit_scene": + args_str = tc.get("function", {}).get("arguments", "{}") + try: + scene_data = _json.loads(args_str) if args_str else {} + if scene_data.get("narrative"): + step_text = scene_data["narrative"] + if scene_data.get("options") and isinstance(scene_data["options"], list): + step_options = [str(o) for o in scene_data["options"]][:5] + except _json.JSONDecodeError: + log.warning("intro_scene_invalid_json", args=args_str[:200]) + break + else: + # Fallback: extract JSON from text. + import re as _re + json_match = _re.search(r"\{[\s\S]*\}", step_resp.text or "") + if json_match: + try: + step_data = _json.loads(json_match.group(0)) + if "narrative" in step_data: + step_text = step_data["narrative"] + if "options" in step_data and isinstance(step_data["options"], list): + step_options = [str(o) for o in step_data["options"]][:5] + except _json.JSONDecodeError: + pass + + # Save as a narrative_step message flagged as intro in payload. + step_seq = await _next_seq(db, session_id) + step_msg = Message( + session_id=session_id, + seq=step_seq, + role="assistant", + kind="narrative_step", + content=step_text, + payload={ + "kind": "intro", + "options": step_options, + "world_time": world.current_time, + "player_state": world.state.get("player", {}), + }, + is_pinned=True, + hidden=False, + ) + db.add(step_msg) + session.last_played_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(step_msg) + + yield { + "type": "step_complete", + "data": { + "message_id": str(step_msg.id), + "seq": step_msg.seq, + "narrative": step_text, + "options": step_options, + "state": world.state, + "world_time": world.current_time, + "player_state": world.state.get("player", {}), + "fired_triggers": [], + "is_intro": True, + }, + } + yield {"type": "done", "data": {}} diff --git a/backend/app/engine/world_editor.py b/backend/app/engine/world_editor.py new file mode 100644 index 0000000..6f1a405 --- /dev/null +++ b/backend/app/engine/world_editor.py @@ -0,0 +1,279 @@ +"""AI-assisted editor for an EXISTING world. + +The player chats with the AI; each turn the AI returns: + - a short player-facing message describing what it changed / will change, + - an updated `definition` (the full new WorldDefinition). + +The caller (API endpoint) decides whether to persist the new definition +to the World row. The editor itself is stateless aside from the in-memory +dialogue cache (keyed by world_id), so the player can iterate. +""" +from __future__ import annotations + +import json +import uuid +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.llm import LlmClient +from app.core.settings_service import cast_setting, get_all_settings +from app.logging_setup import get_logger +from app.models import User, World +from app.prompts.templates import get_prompt +from app.schemas import WorldDefinition +from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS + +log = get_logger("world_editor") + + +# In-memory dialogue cache: world_id -> list of messages. +_DIALOGUES: Dict[uuid.UUID, Dict[str, Any]] = {} + + +async def edit_world_via_chat( + db: AsyncSession, + user: User, + world: World, + message: str, +) -> Tuple[str, Optional[Dict[str, Any]], bool]: + """Run one turn of AI-assisted world editing. + + Returns (ai_message, new_definition_dict_or_None, changed). + - ai_message: short prose reply for the player (in world.language). + - new_definition_dict: the full updated definition if the AI proposed + changes this turn, else None. + - changed: True if new_definition_dict is not None and differs from + the current world.definition. + """ + settings_map = await get_all_settings(db) + llm = LlmClient(settings_map) + + # Get / init dialogue state for this world. + dialogue = _DIALOGUES.get(world.id) + if not dialogue: + system_prompt = _build_system_prompt(world) + dialogue = { + "user_id": user.id, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": _build_seed_message(world)}, + ], + } + _DIALOGUES[world.id] = dialogue + + # Authorization: only the owner (or admin) may continue an existing dialogue. + if dialogue["user_id"] != user.id and not user.is_admin: + raise ValueError("forbidden") + + dialogue["messages"].append({"role": "user", "content": message}) + + response = await llm.chat( + messages=dialogue["messages"], + tools=WORLD_BUILDER_TOOL_SCHEMAS, + temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))), + purpose="world_editor", + user_id=user.id, + db=db, + ) + + ai_message, new_defn, _is_final = _extract_world_definition( + response.text, response.tool_calls, + ) + + # If the model did not return a tool call but did emit JSON in text, + # _extract_world_definition handles it. If still None, just return the + # conversational message without changes. + if new_defn is None: + dialogue["messages"].append({ + "role": "assistant", + "content": response.text or "", + "tool_calls": response.tool_calls or None, + }) + return ai_message, None, False + + new_defn_dict = new_defn.model_dump() + changed = new_defn_dict != (world.definition or {}) + + dialogue["messages"].append({ + "role": "assistant", + "content": response.text or "", + "tool_calls": response.tool_calls or None, + }) + return ai_message, new_defn_dict, changed + + +def reset_editor_dialogue(world_id: uuid.UUID) -> None: + """Drop the cached editor dialogue for a world (e.g. after manual save).""" + _DIALOGUES.pop(world_id, None) + + +def _build_system_prompt(world: World) -> str: + """System prompt for the world editor. + + Reuses the world-builder prompt but overrides the workflow: instead of + designing from scratch, the AI is told to MODIFY the existing definition. + """ + base = get_prompt("world_builder", world.language) + override = ( + "\n\nADDITIONAL CONTEXT — YOU ARE EDITING AN EXISTING WORLD:\n" + "The world already exists with the definition provided in the first " + "user message. The player will give you edit instructions in their " + "language ({world_language}). For EACH instruction:\n" + "1. Call `submit_world_definition` with the FULL updated definition " + "(not just the changed fields — the entire object, all required keys).\n" + "2. Your text response should briefly summarize what you changed in " + "the player's language. 2-4 sentences max.\n" + "3. NEVER set `is_final=true` — the player will commit changes " + "manually via the Save button.\n" + "4. Preserve `initial_state` consistency with `world_schema`. If you " + "change the schema, update the state accordingly.\n" + "5. Preserve `initial_time` and `calendar` unless the player asks to " + "change them.\n" + ).format(world_language=world.language or "en") + return base + override + + +def _build_seed_message(world: World) -> str: + """First user message: dumps the current world definition as context.""" + defn = world.definition or {} + parts = [ + "=== CURRENT WORLD DEFINITION ===", + f"Name: {world.name}", + f"Language: {world.language}", + f"Current time: {world.current_time or '(none)'}", + f"Definition JSON:\n```json\n{json.dumps(defn, ensure_ascii=False, indent=2)}\n```", + f"Live state JSON:\n```json\n{json.dumps(world.state or {}, ensure_ascii=False, indent=2)[:2000]}\n```", + "", + "The player will now give you edit instructions. Apply each one by " + "calling submit_world_definition with the FULL updated definition.", + ] + return "\n".join(parts) + + +# === Output extraction (mirrors world_builder._extract_world_definition) === +def _extract_world_definition( + text: str, + tool_calls: Optional[List[Dict[str, Any]]], +) -> Tuple[str, Optional[WorldDefinition], bool]: + import re as _re + + proposed: Optional[WorldDefinition] = None + is_final = False + ai_text = _sanitize_model_text(text or "") + + if tool_calls: + for tc in tool_calls: + if tc.get("function", {}).get("name") == "submit_world_definition": + args_str = tc.get("function", {}).get("arguments", "{}") + data = _safe_json_loads(args_str, {}) + if data: + proposed = _try_build_definition(data) + is_final = bool(data.get("is_final", False)) + break + + if proposed is None: + json_str = _extract_json_block(ai_text) + if json_str: + data = _safe_json_loads(json_str, None) + if isinstance(data, dict): + target = data + if "proposed_definition" in data and isinstance(data["proposed_definition"], dict): + target = data["proposed_definition"] + if "is_final" in data: + is_final = bool(data["is_final"]) + if "ai_message" in data and isinstance(data["ai_message"], str): + ai_text = data["ai_message"] + else: + if "is_final" in data: + is_final = bool(data["is_final"]) + proposed = _try_build_definition(target) + + if proposed is not None: + ai_text = _strip_json_blocks(ai_text).strip() + if not ai_text: + ai_text = "(definition updated)" + + return ai_text, proposed, is_final + + +def _sanitize_model_text(text: str) -> str: + if not text: + return "" + import re as _re + cleaned = _re.sub(r"<\s*/?\s*[a-zA-Z]+\s*>", "", text) + cleaned = _re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned.strip() + + +def _strip_json_blocks(text: str) -> str: + if not text: + return "" + import re as _re + cleaned = _re.sub(r"```[a-zA-Z]*\s*[\s\S]*?```", "", text) + m = _re.search(r"\n\{[\s\S]*\}\s*$", cleaned) + if m: + cleaned = cleaned[: m.start()] + cleaned[m.end():] + return cleaned + + +def _safe_json_loads(s: str, default: Any) -> Any: + if not s: + return default + try: + return json.loads(s) + except json.JSONDecodeError: + pass + import re as _re + repaired = _re.sub(r",\s*([}\]])", r"\1", s) + try: + return json.loads(repaired) + except json.JSONDecodeError: + pass + try: + repaired2 = repaired.replace("'", '"') + return json.loads(repaired2) + except json.JSONDecodeError: + return default + + +def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]: + try: + return WorldDefinition.model_validate(data) + except Exception as e: + log.warning("world_editor_definition_invalid", error=str(e), keys=list(data.keys())) + return None + + +def _extract_json_block(text: str) -> Optional[str]: + if not text: + return None + import re as _re + m = _re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text) + if m: + return m.group(1) + start = text.find("{") + if start == -1: + return None + depth = 0 + in_str = False + esc = False + for i in range(start, len(text)): + c = text[i] + if in_str: + if esc: + esc = False + elif c == "\\": + esc = True + elif c == '"': + in_str = False + else: + if c == '"': + in_str = True + elif c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return text[start:i + 1] + return None diff --git a/backend/app/migrations/init_db.py b/backend/app/migrations/init_db.py index b864316..368c763 100644 --- a/backend/app/migrations/init_db.py +++ b/backend/app/migrations/init_db.py @@ -52,6 +52,8 @@ DEFAULT_SETTINGS = [ ("embedding.model", settings.default_embedding_model, "Embedding model name (e.g. text-embedding-3-small, bge-m3, nomic-embed-text)"), ("embedding.dim", settings.default_embedding_dim, "Vector dimension. 0 = auto-probe from endpoint on first use"), ("embedding.request_timeout", settings.default_embedding_request_timeout, "Embeddings request timeout, seconds"), + # UI customization + ("ui.logo_url", "/logo.png", "Logo image URL or path shown in navbar, home page, and favicon. Use a URL (https://...), an absolute path (/logo.png), or a data: URI. Default is the bundled Mikan logo."), ] diff --git a/backend/app/prompts/templates.py b/backend/app/prompts/templates.py index 3e94e48..276f083 100644 --- a/backend/app/prompts/templates.py +++ b/backend/app/prompts/templates.py @@ -133,6 +133,35 @@ Call the `submit_trigger_result` tool with: Your text response is ignored — only the tool call is used.""" + + + +# === Intro Scene (opening scene generated when a session starts) === +INTRO_SCENE_SYSTEM = """You are the narrative writer of a role-playing game. The player has just created a new session and you must write the OPENING scene that sets the stage for the adventure. + +CONTEXT: +- Setting: {setting_description} +- Initial world time: {current_time} +- Player state: {player_state} +- Plot rails (main goal + hooks): {plot_rails} + +YOUR JOB: +1. Call the `submit_scene` tool with: + - `narrative`: 250-500 words of cinematic, second-person ("You...") prose that: + a) establishes the setting and mood, + b) introduces the player character based on `player_state`, + c) plants the seed of the main goal / first hook from `plot_rails`, + d) ends with a clear decision moment or call to action. + - `options`: exactly 3 short (5-12 words) options for the player's first action. + +CRITICAL: +- Write the narrative in {world_language}. +- Do NOT assume the player has done anything yet — this is the very first scene. +- Do NOT use the player's name if it is empty or generic; address them as "you". +- Set the tone: atmospheric, evocative, but grounded in the setting. +- Your text response is ignored — only the `submit_scene` tool call is used. +""" + PROMPTS = { "en": { "world_builder": WORLD_BUILDER_SYSTEM, @@ -141,6 +170,7 @@ PROMPTS = { "summarizer": SUMMARIZER_SYSTEM, "subagent": SUBAGENT_SYSTEM, "trigger_runner": TRIGGER_RUNNER_SYSTEM, + "intro_scene": INTRO_SCENE_SYSTEM, }, # Russian keys kept for backward compatibility but always return the English # prompts — system content is always English per the project convention. @@ -151,6 +181,7 @@ PROMPTS = { "summarizer": SUMMARIZER_SYSTEM, "subagent": SUBAGENT_SYSTEM, "trigger_runner": TRIGGER_RUNNER_SYSTEM, + "intro_scene": INTRO_SCENE_SYSTEM, }, } diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 0de50cd..3a74192 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -237,3 +237,15 @@ class TriggerCreate(BaseModel): fire_at: str description: str payload: Dict[str, Any] = Field(default_factory=dict) + + + +# === World Editor (AI-assisted editing of an existing world) === +class WorldEditorChatRequest(BaseModel): + message: str = Field(min_length=1, max_length=4000) + + +class WorldEditorChatReply(BaseModel): + ai_message: str + definition: Optional[Dict[str, Any]] = None + changed: bool = False diff --git a/frontend/index.html b/frontend/index.html index 0dd4b7f..1ba4c7f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,8 +1,9 @@ - + - + + AI RPG diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..73039fc Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6de96dd..99acaea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ +import { useEffect } from "react"; import { Routes, Route, Navigate } from "react-router-dom"; import { useAuthStore } from "@/store/auth"; +import { useUiStore } from "@/store/ui"; import { Navbar } from "@/components/ui/Navbar"; import { HomePage } from "@/pages/HomePage"; import { LoginPage } from "@/pages/LoginPage"; @@ -26,6 +28,15 @@ function AdminRoute({ children }: { children: JSX.Element }) { } export default function App() { + // Load public UI settings (logo URL, etc.) once on app boot. These are + // unauthenticated and cached by the api layer, so subsequent navigations + // do not re-fetch. The admin panel calls load(true) after saving to + // pick up a new logo URL without a full page reload. + const loadUi = useUiStore((s) => s.load); + useEffect(() => { + loadUi(); + }, [loadUi]); + return (
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6fa0681..790ba04 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -229,4 +229,41 @@ export const miscApi = { }, }; +// Public UI settings — no auth required. Used on login/register/home pages +// to render branding (logo, eventually theme). Caches the result in-process +// so multiple components can call getPublicSettings() without re-fetching. +export type PublicUiSettings = { + logo_url?: string; +}; + +let _publicSettingsCache: PublicUiSettings | null = null; +let _publicSettingsPromise: Promise | null = null; + +export const uiApi = { + /** Fetch public UI settings (logo URL, etc.). Cached after first call. */ + getPublicSettings: async (force = false): Promise => { + if (_publicSettingsCache && !force) return _publicSettingsCache; + if (_publicSettingsPromise && !force) return _publicSettingsPromise; + _publicSettingsPromise = (async () => { + try { + const { data } = await api.get("/settings/public"); + _publicSettingsCache = { + logo_url: data["ui.logo_url"] || "/logo.png", + }; + } catch { + _publicSettingsCache = { logo_url: "/logo.png" }; + } finally { + _publicSettingsPromise = null; + } + return _publicSettingsCache; + })(); + return _publicSettingsPromise; + }, + /** Reset the in-memory cache. Call after admin saves new ui.logo_url. */ + resetCache: () => { + _publicSettingsCache = null; + _publicSettingsPromise = null; + }, +}; + export const SSE_ENDPOINT = "/api/sessions"; diff --git a/frontend/src/components/ui/Navbar.tsx b/frontend/src/components/ui/Navbar.tsx index 81d4ad1..e3ef0d0 100644 --- a/frontend/src/components/ui/Navbar.tsx +++ b/frontend/src/components/ui/Navbar.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; import { LogOut, Shield, Globe, BookOpen } from "lucide-react"; import { useAuthStore } from "@/store/auth"; +import { useUiStore } from "@/store/ui"; import { Button } from "./Button"; import { cn } from "./cn"; @@ -11,6 +12,7 @@ export function Navbar() { const { user, logout, isAdmin } = useAuthStore(); const navigate = useNavigate(); const [langOpen, setLangOpen] = useState(false); + const logoUrl = useUiStore((s) => s.logoUrl); const handleLogout = () => { logout(); @@ -26,7 +28,21 @@ export function Navbar() {
- + {logoUrl ? ( + logo { + // If the configured logo fails to load, hide the broken image + // so the navbar degrades gracefully. The bundled /logo.png is + // always available as the default fallback. + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + )} {t("app.title")} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index ba0a8b2..32ba217 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -59,6 +59,14 @@ export const en = { send: "Send", accept: "Accept world and create", accepting: "Creating world...", + editor_chat_title: "AI chat", + editor_chat_desc: "Describe changes — the AI will update the world definition.", + editor_chat_empty: "Nothing yet. Tell the AI what to change.", + editor_chat_ph: "e.g. add a vampire faction in the south...", + editor_you: "You", + editor_pending_defn: "AI proposed a new definition.", + editor_apply: "Apply", + editor_reset: "Reset chat", status_draft: "Draft", status_ready: "Ready", status_active: "Active", @@ -84,6 +92,7 @@ export const en = { no_messages: "Start with your first action!", new_session: "New session", error_iter: "Iteration failed", + retry: "Retry", }, admin: { title: "Admin panel", @@ -106,6 +115,12 @@ export const en = { trigger_settings_desc: "Fire when in-world time advances (no polling)", triggers_enabled: "Enabled", triggers_enabled_desc: "Triggers fire automatically inside the orchestrator when world time advances past their scheduled fire_at.", + ui_settings: "UI customization", + ui_settings_desc: "Branding shown to all users (logo, favicon).", + ui_logo_url: "Logo URL", + ui_logo_url_hint: + "Path (e.g. /logo.png), full URL (https://.../logo.png), or data: URI. Default /logo.png is the bundled Mikan logo. Used in navbar, home page, and browser tab.", + ui_logo_preview: "Preview", embedding_settings: "Embeddings (RAG)", embedding_provider: "Provider", embedding_provider_hash: "Hash (offline fallback, no semantics)", diff --git a/frontend/src/i18n/ru.ts b/frontend/src/i18n/ru.ts index 31e12fd..1fdf504 100644 --- a/frontend/src/i18n/ru.ts +++ b/frontend/src/i18n/ru.ts @@ -59,6 +59,14 @@ export const ru = { send: "Отправить", accept: "Принять мир и создать", accepting: "Создаём мир...", + editor_chat_title: "Чат с ИИ", + editor_chat_desc: "Опишите изменения — ИИ обновит определение мира.", + editor_chat_empty: "Пока пусто. Напишите, что изменить в мире.", + editor_chat_ph: "Например: добавь фракцию вампиров на юге...", + editor_you: "Вы", + editor_pending_defn: "ИИ предложил новое определение.", + editor_apply: "Применить", + editor_reset: "Сбросить диалог", status_draft: "Черновик", status_ready: "Готов", status_active: "Активен", @@ -84,6 +92,7 @@ export const ru = { no_messages: "Начните с первого действия!", new_session: "Новая сессия", error_iter: "Ошибка при выполнении итерации", + retry: "Повторить", }, admin: { title: "Панель администратора", @@ -106,6 +115,12 @@ export const ru = { trigger_settings_desc: "Срабатывают при сдвиге внутриигрового времени (без поллинга)", triggers_enabled: "Включены", triggers_enabled_desc: "Триггеры срабатывают автоматически внутри оркестратора, когда время мира проходит запланированное fire_at.", + ui_settings: "Настройки интерфейса", + ui_settings_desc: "Брендинг, видимый всем пользователям (логотип, favicon).", + ui_logo_url: "URL логотипа", + ui_logo_url_hint: + "Путь (напр. /logo.png), полный URL (https://.../logo.png) или data: URI. По умолчанию /logo.png — встроенный логотип Mikan. Используется в навбаре, на главной и во вкладке браузера.", + ui_logo_preview: "Превью", embedding_settings: "Эмбеддинги (RAG)", embedding_provider: "Провайдер", embedding_provider_hash: "Hash (офлайн-фолбэк, без семантики)", diff --git a/frontend/src/pages/AdminPanelPage.tsx b/frontend/src/pages/AdminPanelPage.tsx index 349484a..c10ecb9 100644 --- a/frontend/src/pages/AdminPanelPage.tsx +++ b/frontend/src/pages/AdminPanelPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { adminApi } from "@/api"; +import { adminApi, uiApi } from "@/api"; import type { LlmLog, SettingsOut } from "@/types"; +import { useUiStore } from "@/store/ui"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Card, CardBody, CardHeader } from "@/components/ui/Card"; @@ -63,6 +64,12 @@ export function AdminPanelPage() { setValues(s.values); setSaved(true); setTimeout(() => setSaved(false), 2000); + // If the admin changed the logo URL, refresh the public-UI cache so + // the navbar/favicon update live without a full page reload. + if ("ui.logo_url" in payload) { + uiApi.resetCache(); + await useUiStore.getState().load(true); + } } catch (err: any) { setError(err.response?.data?.detail || t("errors.unknown")); } finally { @@ -441,6 +448,40 @@ export function AdminPanelPage() { + + + + setValues({ ...values, "ui.logo_url": e.target.value })} + placeholder="/logo.png" + /> +

{t("admin.ui_logo_url_hint")}

+ {/* Live preview so the admin sees the configured logo before saving. */} +
+ {t("admin.ui_logo_preview")}: +
+ {values["ui.logo_url"] ? ( + preview { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + )} +
+
+
+
+
{saved && {t("admin.saved")}}