diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f67e95..8333e25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,48 @@ All notable changes to AI-RPG are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.4.0] — 2026-06-21
+
+Major UX release: redirect to edit after world creation, environment panel, admin separate routes, collapsible settings, name bank, page titles.
+
+### Backend — Critical fixes
+
+- **KeyError in world_builder_schema prompt**: the prompt contained a literal `{name, type, required, min, max, properties}` brace group that `str.format()` tried to interpret as a format field. Rewrote the prompt to describe field properties in prose. All 11 prompts verified to format correctly.
+- **propose_changes not applying world.name / world.description**: `apply_diff` only handled `environment.*` paths. Now supports shorthand paths: `name`, `description`, `language`, `player.*`, `current_location`, `plot_rails.*`, `schemas`. Also skips empty `{}` new values (model sometimes returns empty objects). Also syncs `world.plot_rails` column from environment.
+- **action_source validation error**: `IterateRequest.action_source` was `Literal["custom", "suggested"]` which rejected any other string. Changed to `str` with default "custom" — accepts any value.
+- **Tool-call retry with nudge**: when the model returns no tool calls, the loop now appends a system message: "You did not call any tools. You MUST use the available tools. If you tried to call a tool but it didn't work, try again with proper JSON arguments." and retries (up to max_substeps).
+- **Text replacements applied earlier**: now applied to LLM content in the tool loop (not just scene_text), so reasoning/comments are also cleaned before being shown to the user or fed back to the model.
+- **Language instruction in prompts**: added "Language rule" section to `orchestrator_phase1`, `world_builder_schema`, and other prompts: "All entity names, location names, character names, item names, and descriptions that the PLAYER will see MUST be in `{language}`. Internal reasoning and tool arguments stay in English."
+- **World description from preset**: when creating a world from a preset, `world.description` is now set from `preset.description` (was using `body.notes` or null).
+- **Schema `show` field**: `world_builder_schema` prompt now instructs the LLM to include a `show` boolean on each field (default true; if false, the field is hidden from the player UI).
+
+### Backend — New settings & endpoints
+
+- **`ui.header_title` setting**: separate title for the navbar header. If empty, falls back to `ui.page_title`. Returned in `GET /api/settings/public` as `header_title`.
+- **`character_names.en` / `character_names.ru` settings**: name banks for the random name button. Each is a JSON array of ~20 names. Extensible via admin settings.
+- **`GET /api/names/{language}`** (no auth) — returns `{name: "random name", language, count}`. Used by the world builder form's 🎲 button.
+
+### Frontend — 16 files changed
+
+- **Redirect to edit after world creation**: WorldBuilder now redirects to `/worlds/{id}/edit` (not `/play`) after the builder stream completes. The user manually clicks "Generate intro scene" then "Play".
+- **Play page — intro_scene + suggested actions**: if no recent_steps but `world.intro_scene` exists, it's shown as the first assistant message. `next_actions` shown as clickable buttons. Empty states with links to edit page.
+- **Environment panel**: shows Player (name, HP progress bar, mana/strength, inventory), Current Location, Plot Rails (hooks, current_goals, completed_goals). "No environment data" empty state.
+- **Edit world button**: ⚙️ button in PlayPage header → links to `/worlds/{id}/edit`.
+- **Admin separate routes**: `/admin/stats`, `/admin/logs`, `/admin/users`, `/admin/settings`, `/admin/test`, `/admin/icons`. `/admin` redirects to `/admin/stats`. Page reload preserves the current tab.
+- **LLM logs auto-refresh**: polls every 5 seconds (first page only, paused when filter inputs are focused). Pause/Resume button. "N new" badge when new logs arrive.
+- **Collapsible settings cards**: all 7 cards (LLM, Embeddings, Qdrant, Context, Game, UI, Text Replacements) are collapsible, default collapsed. Chevron icon (▶/▼). "Expand all" / "Collapse all" buttons.
+- **Boolean settings → checkboxes**: replaced dropdowns with `` for all boolean settings.
+- **Localized setting descriptions**: all ~36 setting descriptions now have translation keys (`admin.setting_desc.{key}`). Also localized the test page hints and the "Each card saves independently..." text.
+- **Page title**: PlayPage and WorldEditPage set `document.title = "{world.name} | {headerTitle}"`. Navbar uses `header_title` from `/api/settings/public`.
+- **World builder form simplified**: removed `form_data` JSON. Now has: Setting (textarea), World name (default "New World" / "Новый Мир"), Player name (text + 🎲 random button), Language (select), Notes (textarea). Preset mode hides Setting field, defaults world name to preset name.
+- **Name bank random button**: 🎲 button next to player name. Calls `GET /api/names/{language}` and fills the field. Disabled while fetching.
+- **World edit — current_time_human**: shows "Day 1, 08:00" instead of "day_1_hour_8".
+- **World editor — comment + tool_call display**: `comment` SSE events shown as assistant chat bubbles. `tool_call` events shown as ToolCallBubble components in the chat log.
+
+### Verification
+- Backend: 68 unit tests pass, 53 routes.
+- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (393 KB JS / 24 KB CSS, ~119 KB gzipped).
+
## [1.3.0] — 2026-06-21
Major release: world builder rewritten to use tools (instead of JSON), resumable builder flow, admin recovery, LLM model list, text replacements, human-readable time.
diff --git a/app/api/misc.py b/app/api/misc.py
index 242e7c8..4e0566f 100644
--- a/app/api/misc.py
+++ b/app/api/misc.py
@@ -68,14 +68,36 @@ async def public_settings(db: AsyncSession = Depends(get_db)) -> dict:
favicon_url = await get_setting(db, "ui.favicon_url")
logo_url = await get_setting(db, "ui.logo_url")
og_image_url = await get_setting(db, "ui.og_image_url")
+ header_title = await get_setting(db, "ui.header_title")
return {
"page_title": page_title or "AI-RPG",
"favicon_url": favicon_url or "/icon.png",
"logo_url": logo_url or "/icon.png",
"og_image_url": og_image_url or "",
+ "header_title": header_title or page_title or "AI-RPG",
}
+@router.get("/names/{language}")
+async def get_name_bank(
+ language: str,
+ db: AsyncSession = Depends(get_db),
+) -> dict:
+ """Return a random character name for the given language.
+
+ No auth required — used by the world builder form's "random name" button.
+ """
+ import random
+
+ key = f"character_names.{language}"
+ names = await get_setting(db, key)
+ if not names or not isinstance(names, list):
+ # Fallback to English
+ names = await get_setting(db, "character_names.en") or ["Hero"]
+ pick = random.choice(names) if names else "Hero"
+ return {"name": pick, "language": language, "count": len(names)}
+
+
@router.get("/i18n/{lang}")
async def i18n(lang: str) -> dict:
"""Return translation JSON for the given language."""
diff --git a/app/api/worlds.py b/app/api/worlds.py
index 82dc973..c6d3c40 100644
--- a/app/api/worlds.py
+++ b/app/api/worlds.py
@@ -105,7 +105,7 @@ async def create_world(
owner_id=user.id,
preset_id=preset.id if preset else None,
name=body.name,
- description=body.notes,
+ description=preset.description if preset else (body.notes or None),
language=body.language,
status="draft",
current_time="day_1_hour_8",
diff --git a/app/core/settings_service.py b/app/core/settings_service.py
index 924ef17..5d2b939 100644
--- a/app/core/settings_service.py
+++ b/app/core/settings_service.py
@@ -60,6 +60,20 @@ DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
"value": [],
"description": "List of {from, to} pairs. Each 'from' substring in LLM scene_text output is replaced with 'to' (can be empty string to remove).",
},
+ "ui.header_title": {
+ "value": "",
+ "description": "Header title in the navbar. If empty, falls back to ui.page_title.",
+ },
+ "character_names.en": {
+ "value": ["Eric", "Lyra", "Kael", "Mira", "Thorne", "Elara", "Gareth", "Sera", "Darian", "Ivy",
+ "Bran", "Wren", "Callum", "Astrid", "Rurik", "Faye", "Owen", "Selene", "Magnus", "Tara"],
+ "description": "Character name bank for English worlds (used by random name button).",
+ },
+ "character_names.ru": {
+ "value": ["Эрик", "Элара", "Мира", "Каэль", "Гарет", "Сера", "Дариан", "Тара", "Бран", "Рен",
+ "Алексей", "Мария", "Иван", "Ольга", "Дмитрий", "Анна", "Сергей", "Елена", "Андрей", "Наталья"],
+ "description": "Character name bank for Russian worlds (used by random name button).",
+ },
}
# Keys whose values should never be returned to the client in plaintext.
diff --git a/app/engine/world_builder.py b/app/engine/world_builder.py
index 7f63b30..07ad628 100644
--- a/app/engine/world_builder.py
+++ b/app/engine/world_builder.py
@@ -341,13 +341,26 @@ async def _run_tool_loop(
"tokens": (resp.get("prompt_tokens") or 0) + (resp.get("completion_tokens") or 0),
})
msg = resp.get("message", {})
+ # Apply text replacements to content
+ content = msg.get("content", "") or ""
+ if content:
+ from app.core.settings_service import apply_text_replacements
+ content = await apply_text_replacements(db, content)
+ msg = dict(msg)
+ msg["content"] = content
tool_calls = msg.get("tool_calls") or []
if not tool_calls:
- # No tool calls — append assistant message and ask again
+ # No tool calls — append assistant message and retry with a nudge.
+ # Up to 3 retries.
messages.append({"role": "assistant", "content": msg.get("content", "")})
messages.append({
"role": "user",
- "content": f"You must call a tool. Available terminal tool: {terminal_tool}. If you are done with your work, call {terminal_tool} now.",
+ "content": (
+ f"You did not call any tools in your previous response. "
+ f"You MUST use the available tools to accomplish the task. "
+ f"If you tried to call a tool but it didn't work, try again with proper JSON arguments. "
+ f"When you are done, call {terminal_tool}."
+ ),
})
continue
diff --git a/app/engine/world_editor.py b/app/engine/world_editor.py
index 0d2f136..d6b4b91 100644
--- a/app/engine/world_editor.py
+++ b/app/engine/world_editor.py
@@ -217,10 +217,16 @@ async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None:
"""Apply a propose_changes diff to the world.
Supported path formats:
- - `environment.` → apply_patch to world.environment
- `world.name` → set world.name
- `world.description` → set world.description
+ - `world.language` → set world.language
- `schemas` → replace entire world.schemas
+ - `schemas.` → replace a single schema type
+ - `environment.` → apply_patch to world.environment
+ - `environment` → replace entire world.environment
+ - `player.` → shorthand for environment.player.
+ - `current_location` → shorthand for environment.current_location
+ - `plot_rails.` → shorthand for environment.plot_rails.
"""
from app.core.state_validator import apply_patch
@@ -231,25 +237,69 @@ async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None:
path = d.get("path", "") or ""
op = d.get("op", "replace")
new = d.get("new")
- if path.startswith("environment."):
- field = path[len("environment."):]
- env_patch[field] = new
- elif path == "world.name":
- if isinstance(new, str):
+ old = d.get("old") # noqa: F841 — not used but part of the schema
+
+ # Skip if new is empty dict {} (model sometimes returns empty)
+ if isinstance(new, dict) and not new and op == "replace":
+ _logger.warning("apply_diff_skip_empty", path=path)
+ continue
+ if new is None and op == "replace":
+ continue
+
+ # World-level fields
+ if path == "world.name" or path == "name":
+ if isinstance(new, str) and new.strip():
world.name = new
- elif path == "world.description":
- if new is None or isinstance(new, str):
+ elif path == "world.description" or path == "description":
+ if isinstance(new, str):
world.description = new
+ elif path == "world.language" or path == "language":
+ if isinstance(new, str):
+ world.language = new
+ # Schemas
elif path == "schemas":
if isinstance(new, list):
world.schemas = new
elif path.startswith("schemas."):
- # For simplicity, replace entire schemas
if isinstance(new, list):
world.schemas = new
+ # Environment (full replace)
+ elif path == "environment":
+ if isinstance(new, dict):
+ world.environment = new
+ # Environment field paths
+ elif path.startswith("environment."):
+ field = path[len("environment."):]
+ env_patch[field] = new
+ # Shorthand: player.xxx → environment.player.xxx
+ elif path.startswith("player."):
+ field = path[len("player."):]
+ env_patch[f"player.{field}"] = new
+ elif path == "player":
+ if isinstance(new, dict):
+ env_patch["player"] = new
+ # Shorthand: current_location
+ elif path == "current_location":
+ if isinstance(new, str):
+ env_patch["current_location"] = new
+ elif isinstance(new, dict) and "name" in new:
+ env_patch["current_location"] = new["name"]
+ # Shorthand: plot_rails.xxx
+ elif path.startswith("plot_rails."):
+ field = path[len("plot_rails."):]
+ env_patch[f"plot_rails.{field}"] = new
+ elif path == "plot_rails":
+ if isinstance(new, dict):
+ env_patch["plot_rails"] = new
+ else:
+ _logger.warning("apply_diff_unknown_path", path=path)
+
if env_patch:
new_env, errors = apply_patch(dict(world.environment or {}), env_patch)
if not errors:
world.environment = new_env
+ # Sync plot_rails column
+ if isinstance(new_env.get("plot_rails"), dict):
+ world.plot_rails = new_env["plot_rails"]
else:
_logger.warning("apply_diff_errors", errors=errors)
diff --git a/app/prompts/stages/orchestrator_phase1.py b/app/prompts/stages/orchestrator_phase1.py
index 2812324..fa8af71 100644
--- a/app/prompts/stages/orchestrator_phase1.py
+++ b/app/prompts/stages/orchestrator_phase1.py
@@ -3,6 +3,9 @@
PROMPTS = {
"en": """You are the Game Master (GM) of a text RPG in the world "{world_name}".
+# Language rule
+The world's language is `{language}`. All entity names, location names, character names, item names, and descriptions that the PLAYER will see MUST be in `{language}`. Internal reasoning and tool arguments stay in English.
+
# Your responsibilities
1. Evaluate the player's action and decide what happened mechanically.
2. Call tools for ANY state change in the world.
diff --git a/app/prompts/stages/world_builder_schema.py b/app/prompts/stages/world_builder_schema.py
index 2567f60..63c9b28 100644
--- a/app/prompts/stages/world_builder_schema.py
+++ b/app/prompts/stages/world_builder_schema.py
@@ -13,29 +13,32 @@ Player name: {player_name}
Language: {language}
Notes: {notes}
+# Language rule
+The world's language is `{language}`. All entity names, location names, character names, item names, and descriptions that the PLAYER will see MUST be in `{language}`. Internal field names (like "health", "stats") stay in English.
+
# Required entity types
You MUST create at minimum these entity types (call schema_add_type for each):
-1. `character` — with fields: name (string, required), description (string), stats (object, required, with sub-fields: health integer 0-100 required, mana integer 0-100, strength integer 1-20), inventory (array), relationship (string)
-2. `item` — with fields: name (string, required), description (string), qty (integer 1-9999), value (integer)
-3. `location` — with fields: name (string, required), description (string, required), exits (array of strings), is_safe (boolean)
-4. `faction` — with fields: name (string, required), description (string), alignment (string)
+1. `character` — fields: name (string, required), description (string), stats (object, required, with sub-fields: health integer 0-100 required, mana integer 0-100, strength integer 1-20), inventory (array), relationship (string)
+2. `item` — fields: name (string, required), description (string), qty (integer 1-9999), value (integer)
+3. `location` — fields: name (string, required), description (string, required), exits (array of strings), is_safe (boolean)
+4. `faction` — fields: name (string, required), description (string), alignment (string)
You may add additional entity types if the setting requires (e.g. `quest`, `spell`, `vehicle`).
# schema_add_type arguments
-Each call to schema_add_type needs:
+Each call to schema_add_type needs these arguments:
- `type`: lowercase identifier like "character"
- `verbose`: display name like "Character"
- `plural`: plural form like "characters"
-- `properties`: array of field definitions, each with {name, type, required, min, max, properties}
+- `properties`: an array of field definition objects. Each field object has these keys: "name" (string), "type" (one of: string, integer, number, boolean, object, array), "required" (boolean), "min" (integer, optional), "max" (integer, optional), "properties" (array, only for type=object), "show" (boolean, default true — if false, the field is hidden from the player UI).
-Example properties for a character type:
+Example properties array for a character type:
```json
[
- {{"name": "name", "type": "string", "required": true}},
- {{"name": "stats", "type": "object", "required": true, "properties": [
- {{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100}},
- {{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100}}
+ {{"name": "name", "type": "string", "required": true, "show": true}},
+ {{"name": "stats", "type": "object", "required": true, "show": false, "properties": [
+ {{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100, "show": true}},
+ {{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100, "show": true}}
]}}
]
```
@@ -47,6 +50,7 @@ Call `submit_plan` with a 1-sentence summary of the world you designed.
- Call schema_add_type for EACH entity type (one call per type).
- After all schema_add_type calls, call submit_plan exactly once.
- After max 10 tool calls you MUST call submit_plan.
+- If you previously tried to call tools but they didn't work, try again — make sure to use proper JSON arguments.
""",
"ru": "",
}
diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py
index 23ca881..82f6299 100644
--- a/app/schemas/__init__.py
+++ b/app/schemas/__init__.py
@@ -144,7 +144,7 @@ class WorldEditRequest(BaseModel):
# --------------------------------------------------------------------------- #
class IterateRequest(BaseModel):
action: str = Field(min_length=1, max_length=4000)
- action_source: Literal["custom", "suggested"] = "custom"
+ action_source: str = "custom" # accept any string, default to "custom"
class AnswerRequest(BaseModel):
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8a3d3a6..09a66d9 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -164,6 +164,14 @@ export default function App() {
/>
+
+
+ }
+ />
+
diff --git a/frontend/src/components/admin/LlmLogsTable.tsx b/frontend/src/components/admin/LlmLogsTable.tsx
index a5d5a96..58bd43e 100644
--- a/frontend/src/components/admin/LlmLogsTable.tsx
+++ b/frontend/src/components/admin/LlmLogsTable.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminApi, toErrorMessage } from "@/lib/api";
import { useToastStore } from "@/stores/toastStore";
@@ -30,6 +30,9 @@ const STAGE_OPTIONS = [
const STATUS_OPTIONS = ["", "ok", "timeout", "api_error", "parse_error", "validation_error"];
+/** Auto-refresh interval (ms). */
+const AUTO_REFRESH_INTERVAL_MS = 5000;
+
function statusColor(status: string): string {
if (status === "ok") return "bg-ok/15 text-ok";
const errKinds = ["timeout", "api_error", "parse_error", "validation_error", "error"];
@@ -51,6 +54,14 @@ export function LlmLogsTable() {
const [detailLoading, setDetailLoading] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
+ // Auto-refresh state.
+ const [autoRefresh, setAutoRefresh] = useState(true);
+ const [newLogsCount, setNewLogsCount] = useState(0);
+ // True while any filter input/select has focus — we pause polling then so
+ // we don't yank the table out from under the user.
+ const filterFocusRef = useRef(false);
+ const [, forceRerender] = useState(0);
+
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
@@ -62,6 +73,8 @@ export function LlmLogsTable() {
per_page: perPage,
});
setData(res);
+ // Reset the "new logs" counter when we explicitly (re)fetch.
+ setNewLogsCount(0);
} catch (err) {
pushToast("error", toErrorMessage(err));
} finally {
@@ -73,6 +86,40 @@ export function LlmLogsTable() {
void fetchLogs();
}, [fetchLogs]);
+ // Silent auto-refresh polling. Only when:
+ // - autoRefresh is enabled
+ // - user is on the first page
+ // - no filter input is currently focused
+ useEffect(() => {
+ if (!autoRefresh) return;
+ const interval = window.setInterval(async () => {
+ if (page !== 1) return;
+ if (filterFocusRef.current) return;
+ try {
+ const res = await AdminApi.llmLogs({
+ world_id: appliedFilters.world_id || undefined,
+ stage: appliedFilters.stage || undefined,
+ status_filter: appliedFilters.status_filter || undefined,
+ page: 1,
+ per_page: perPage,
+ });
+ setData((prev) => {
+ if (!prev) return res;
+ // Detect new items by comparing top-of-list ids.
+ const prevIds = new Set(prev.items.map((l) => l.id));
+ const newOnes = res.items.filter((l) => !prevIds.has(l.id));
+ if (newOnes.length > 0) {
+ setNewLogsCount((n) => n + newOnes.length);
+ }
+ return res;
+ });
+ } catch {
+ // Silent — don't spam toasts on auto-refresh errors.
+ }
+ }, AUTO_REFRESH_INTERVAL_MS);
+ return () => window.clearInterval(interval);
+ }, [autoRefresh, page, appliedFilters, perPage]);
+
const applyFilters = () => {
setAppliedFilters(filters);
setPage(1);
@@ -92,15 +139,50 @@ export function LlmLogsTable() {
}
};
+ // Filter input focus tracking — we use a wrapping
with onFocus /
+ // onBlur (capture phase) so any input/select inside counts.
+ const handleFilterFocus = () => {
+ filterFocusRef.current = true;
+ forceRerender((n) => n + 1);
+ };
+ const handleFilterBlur = () => {
+ filterFocusRef.current = false;
+ forceRerender((n) => n + 1);
+ };
+
// Short preview of the world_id filter value, for the column header.
const worldFilterPreview = appliedFilters.world_id
? appliedFilters.world_id.slice(0, 8)
: "";
+ const autoRefreshActive = autoRefresh && page === 1 && !filterFocusRef.current;
return (
+ )}
);
}
@@ -376,23 +435,30 @@ interface SettingFieldProps {
function SettingField({ settingKey, description, value, onChange }: SettingFieldProps) {
const { t } = useTranslation();
const ft = fieldType(settingKey);
- const hint = hintFor(settingKey) || description;
+ const localizedDesc = useSettingDesc();
+ const hint = localizedDesc(settingKey, description);
const label = settingKey;
if (ft === "boolean") {
+ // Render booleans as a checkbox (with the key as the label) rather than
+ // a dropdown — it's a more natural control for a true/false toggle.
+ const checked = value === "true";
return (