This commit is contained in:
Mikan
2026-06-21 06:12:28 +03:00
parent 4dee4fb0a8
commit e98559a587
29 changed files with 1248 additions and 267 deletions

View File

@@ -4,6 +4,66 @@ All notable changes to AI-RPG are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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.
### Backend — Critical: World Builder rewritten to use tools
- **`world_builder_schema` stage**: previously asked the LLM to output a JSON object with schemas, which frequently failed validation. Now the LLM calls `schema_add_type` tool for each entity type. Added `world_builder_schema` and `world_builder_env` to the `stages` set of all relevant tools (schema_add_type, env_update, entity_create, submit_plan, etc.).
- **`world_builder_env` stage**: previously asked for JSON. Now the LLM calls `env_update` tool to set player, current_location, and plot_rails.
- **Resumable builder**: each stage checks if the world already has the needed data and skips if so. If schemas exist → skip schema generation. If environment has current_location → skip env generation. If entities exist → skip entity generation. If intro_scene exists → skip intro generation. This allows re-running the builder after a failure at any stage without redoing earlier stages.
- **Validation warnings instead of failures**: if `validate_world` finds issues after env generation, the builder emits a `warning` SSE event but continues (instead of failing). The world may still be usable.
- **Better tool-loop prompt**: the user message now says "Use the available tools to accomplish the task. When done, call {terminal_tool}." to encourage tool use.
- **Updated prompts**: `world_builder_schema` and `world_builder_env` prompts now describe the tools to use and give examples of tool arguments.
### Backend — Critical: World Editor fixes
- **No extra LLM call after propose_changes**: after the user accepts or rejects proposed changes, the editor loop now breaks immediately. Previously it made another LLM call (which returned empty text), wasting API requests.
- **`'str' object has no attribute 'get'` fix**: already in v1.2.0, but now also handles cases where `function` is not a dict.
### Backend — New: Admin recovery
- **`POST /api/admin/recover`** (NO AUTH required) — creates a new admin user using the `admin.setup_token` (printed on every backend startup). Body: `{token, email, username, password}`. For disaster recovery when all existing admins lost access. Returns 403 `invalid_admin_token` if the token doesn't match.
### Backend — New: LLM model list
- **`POST /api/admin/llm/models`** (admin) — fetches the list of available models from an OpenAI-compatible API (`GET {api_url}/models`). Returns `{ok: true, models: [...], count: N}` or `{ok: false, error: {...}, models: []}`. Uses the same `_resolve` helper as test endpoints (ignores masked api_key values).
### Backend — New: Text replacements
- **New setting `llm.text_replacements`**: a JSON array of `{from: string, to: string}` pairs. Applied to all LLM scene_text output (both orchestrator Phase 2 and intro_scene). Use empty `to` to remove a word/phrase entirely.
- **`apply_text_replacements(session, text)`** helper in `settings_service.py`.
- Applied in `game_master.py` (Phase 2 writer) and `world_builder.py` (intro scene).
### Backend — New: Human-readable time
- **`format_time_human(time_str, language)`** in `time_utils.py` — converts `"day_1_hour_8"``"Day 1, 08:00"` (en) or `"День 1, 08:00"` (ru). Supports years, days, hours, minutes.
- **`GET /api/worlds`** now returns `current_time_human` alongside `current_time`.
- **`GET /api/sessions/worlds/{id}/state`** now returns `current_time_human` and `status` in the world object.
### Backend — New: world_id in LLM logs
- **`GET /api/admin/llm-logs`** now includes `world_id` (string UUID or null) on each log item. Useful for the admin UI to show which world a log belongs to, even when not filtering by world_id.
### Backend — Route fix
- **404 on generate-intro**: the frontend was calling `/api/worlds/{id}/generate-intro` but the route is at `/api/sessions/worlds/{id}/generate-intro`. Fixed the frontend API helper to use the correct path.
### Frontend — 14 files changed, 1 new
- **Admin recovery page**: new `/recover` route (public, no auth). Form with token/email/username/password. Link from LoginPage: "Lost admin access? Recover here".
- **LLM model list dropdown**: "Fetch models" button next to the model input in SettingsPanel. Fetches from `POST /api/admin/llm/models`. Shows a `<select>` dropdown on success, or a "Could not fetch" hint on failure. Cached in component state.
- **Text replacements UI**: new card in SettingsPanel with add/remove rows for `{from, to}` pairs. Saved to `llm.text_replacements` setting.
- **World column in LLM logs table**: shows first 8 chars of `world_id` (or "—"). Filter indicator in header. Full `world_id` in detail modal.
- **Human-readable time**: `current_time_human` used in WorldCard, PlayPage, WorldEditPage. Falls back to `current_time` if not available.
- **World builder skipped stages**: `skipping_*` step events shown in muted blue color.
- **IntroSceneGenerator visibility**: shown for draft worlds OR worlds with empty intro_scene (not just stuck worlds).
### Verification
- Backend: 68 unit tests pass, 52 routes.
- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (378 KB JS / 23 KB CSS, ~114 KB gzipped).
## [1.2.0] — 2026-06-21 ## [1.2.0] — 2026-06-21
This release fixes critical bugs that prevented world creation, world editing, and LLM tool-calling with local models (gemma, qwen, etc.). This release fixes critical bugs that prevented world creation, world editing, and LLM tool-calling with local models (gemma, qwen, etc.).

View File

@@ -40,6 +40,76 @@ _logger = get_logger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
# --------------------------------------------------------------------------- #
# Admin recovery — create a new admin when all existing admins lost access.
# This endpoint is NOT behind require_admin (it's for recovery). It requires
# the admin.setup_token from settings, which is printed on every startup.
# --------------------------------------------------------------------------- #
@router.post("/recover", response_model=dict)
async def recover_admin(
body: dict,
db: AsyncSession = Depends(get_db),
) -> dict:
"""Create a new admin user using the admin setup token.
This endpoint is for disaster recovery when all existing admins have lost
access. It requires the `admin.setup_token` (printed on backend startup)
and creates a new admin user.
Body: {token, email, username, password}
"""
from app.core.security import hash_password, validate_password_strength
from app.core.settings_service import get_admin_setup_token
from app.models import User as UserModel
from sqlalchemy import or_
token = body.get("token", "")
expected_token = await get_admin_setup_token(db)
if token != expected_token:
raise HTTPException(403, "invalid_admin_token")
email = body.get("email", "").strip()
username = body.get("username", "").strip()
password = body.get("password", "")
if not email or not username or not password:
raise HTTPException(400, "email, username, and password are required")
errors = validate_password_strength(password)
if errors:
raise HTTPException(400, errors[0])
existing = (
await db.execute(
select(UserModel).where(
or_(UserModel.email == email, UserModel.username == username)
)
)
).scalar_one_or_none()
if existing is not None:
if existing.email == email:
raise HTTPException(400, "email_already_exists")
raise HTTPException(400, "username_already_exists")
user = UserModel(
email=email,
username=username,
password_hash=hash_password(password),
is_admin=True,
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
return {
"ok": True,
"id": str(user.id),
"email": user.email,
"username": user.username,
"is_admin": user.is_admin,
}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Settings # Settings
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -95,8 +165,13 @@ async def list_llm_logs(
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
stmt = stmt.order_by(LlmCallLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page) stmt = stmt.order_by(LlmCallLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
rows = (await db.execute(stmt)).scalars().all() rows = (await db.execute(stmt)).scalars().all()
items = []
for r in rows:
d = LlmLogOut.model_validate(r).model_dump(mode="json")
d["world_id"] = str(r.world_id) if r.world_id else None
items.append(d)
return { return {
"items": [LlmLogOut.model_validate(r).model_dump(mode="json") for r in rows], "items": items,
"total": total, "page": page, "per_page": per_page, "total": total, "page": page, "per_page": per_page,
} }
@@ -217,6 +292,55 @@ async def stats(
} }
# --------------------------------------------------------------------------- #
# LLM model list — fetch available models from the LLM provider
# --------------------------------------------------------------------------- #
@router.post("/llm/models")
async def list_llm_models(
api_url: str | None = None,
api_key: str | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
"""Fetch the list of available models from an OpenAI-compatible API.
Returns {ok: true, models: ["model1", "model2", ...]} on success.
Returns {ok: false, error: {...}} on failure (no auto-fetch available).
"""
import httpx
settings = await get_all_settings(db)
api_url = _resolve(api_url, settings.get("llm.api_url", ""))
api_key = _resolve(api_key, settings.get("llm.api_key", ""))
if not api_url:
return {"ok": False, "error": {"code": "not_configured",
"message": "llm.api_url is empty"},
"models": []}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{api_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {api_key}"} if api_key else {},
)
if resp.status_code >= 400:
return {"ok": False,
"error": {"code": "api_error",
"message": f"HTTP {resp.status_code}: {resp.text[:200]}"},
"models": []}
data = resp.json()
models = []
for m in data.get("data", []):
mid = m.get("id") or m.get("name")
if mid:
models.append(mid)
models.sort()
return {"ok": True, "models": models, "count": len(models)}
except Exception as e: # noqa: BLE001
return {"ok": False,
"error": {"code": "connection_failed", "message": str(e)},
"models": []}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Helpers for test endpoints # Helpers for test endpoints
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

View File

@@ -76,10 +76,14 @@ async def get_state(
next_actions = recent_steps[-1]["suggested_actions"] if recent_steps else [] next_actions = recent_steps[-1]["suggested_actions"] if recent_steps else []
if world.intro_scene and not recent_steps: if world.intro_scene and not recent_steps:
next_actions = [] next_actions = []
from app.core.time_utils import format_time_human
return { return {
"world": { "world": {
"id": str(world.id), "name": world.name, "current_time": world.current_time, "id": str(world.id), "name": world.name,
"current_time": world.current_time,
"current_time_human": format_time_human(world.current_time, world.language),
"language": world.language, "intro_scene": world.intro_scene, "language": world.language, "intro_scene": world.intro_scene,
"status": world.status,
}, },
"environment": world.environment, "environment": world.environment,
"recent_steps": recent_steps, "recent_steps": recent_steps,

View File

@@ -69,11 +69,13 @@ async def list_worlds(
env = w.environment or {} env = w.environment or {}
player = env.get("player") if isinstance(env, dict) else None player = env.get("player") if isinstance(env, dict) else None
pname = player.get("name") if isinstance(player, dict) else None pname = player.get("name") if isinstance(player, dict) else None
from app.core.time_utils import format_time_human
items.append({ items.append({
"id": str(w.id), "name": w.name, "description": w.description, "id": str(w.id), "name": w.name, "description": w.description,
"language": w.language, "status": w.status, "language": w.language, "status": w.status,
"last_played_at": w.last_played_at.isoformat() if w.last_played_at else None, "last_played_at": w.last_played_at.isoformat() if w.last_played_at else None,
"current_time": w.current_time, "current_time": w.current_time,
"current_time_human": format_time_human(w.current_time, w.language),
"created_at": w.created_at.isoformat(), "created_at": w.created_at.isoformat(),
"preview_player_name": pname, "preview_player_name": pname,
}) })

View File

@@ -56,6 +56,10 @@ DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
"ui.logo_url": {"value": "/icon.png", "description": "Logo URL"}, "ui.logo_url": {"value": "/icon.png", "description": "Logo URL"},
"ui.og_image_url": {"value": "", "description": "OpenGraph image URL"}, "ui.og_image_url": {"value": "", "description": "OpenGraph image URL"},
"admin.setup_token": {"value": "", "description": "Admin setup token"}, "admin.setup_token": {"value": "", "description": "Admin setup token"},
"llm.text_replacements": {
"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).",
},
} }
# Keys whose values should never be returned to the client in plaintext. # Keys whose values should never be returned to the client in plaintext.
@@ -185,3 +189,24 @@ async def get_admin_setup_token(session: AsyncSession) -> str:
token = secrets.token_urlsafe(16) token = secrets.token_urlsafe(16)
await set_setting(session, "admin.setup_token", token) await set_setting(session, "admin.setup_token", token)
return token return token
async def apply_text_replacements(session: AsyncSession, text: str) -> str:
"""Apply llm.text_replacements to a text string.
Each replacement is a dict {from: str, to: str}. The 'from' substring is
replaced with 'to' (which can be empty to remove the substring).
"""
if not text:
return text
replacements = await get_setting(session, "llm.text_replacements")
if not replacements or not isinstance(replacements, list):
return text
for r in replacements:
if not isinstance(r, dict):
continue
frm = r.get("from")
to = r.get("to", "")
if frm and isinstance(frm, str):
text = text.replace(frm, to)
return text

View File

@@ -133,6 +133,41 @@ def time_le(a: str, b: str) -> bool:
return ga.total_minutes() <= gb.total_minutes() return ga.total_minutes() <= gb.total_minutes()
def format_time_human(time_str: str, language: str = "en") -> str:
"""Format a game time string as a human-readable localized string.
Examples:
- "day_1_hour_8""Day 1, 08:00" (en) / "День 1, 08:00" (ru)
- "day_3_hour_14_min_30""Day 3, 14:30" (en) / "День 3, 14:30" (ru)
- "year_2_day_5_hour_12""Year 2, Day 5, 12:00" (en) / "Год 2, День 5, 12:00" (ru)
"""
try:
gt = GameTime.parse(time_str)
except ValueError:
return time_str # return as-is if unparseable
if language == "ru":
parts = []
if gt.year != 1:
parts.append(f"Год {gt.year}")
parts.append(f"День {gt.day}")
if gt.minute:
parts.append(f"{gt.hour:02d}:{gt.minute:02d}")
else:
parts.append(f"{gt.hour:02d}:00")
return ", ".join(parts)
else: # en
parts = []
if gt.year != 1:
parts.append(f"Year {gt.year}")
parts.append(f"Day {gt.day}")
if gt.minute:
parts.append(f"{gt.hour:02d}:{gt.minute:02d}")
else:
parts.append(f"{gt.hour:02d}:00")
return ", ".join(parts)
def summarize_schemas(schemas: Iterable[dict]) -> str: def summarize_schemas(schemas: Iterable[dict]) -> str:
"""Render a compact human-readable summary of entity schemas for LLM prompts.""" """Render a compact human-readable summary of entity schemas for LLM prompts."""
lines: list[str] = [] lines: list[str] = []

View File

@@ -112,6 +112,9 @@ async def run_iteration(
if result.ok: if result.ok:
scene_text = result.data.get("scene_text", "") scene_text = result.data.get("scene_text", "")
delta_time = result.data.get("delta_time", "hours_1") delta_time = result.data.get("delta_time", "hours_1")
# Apply text replacements
from app.core.settings_service import apply_text_replacements
scene_text = await apply_text_replacements(db, scene_text)
step.scene_text = scene_text step.scene_text = scene_text
step.scene_delta_time = delta_time step.scene_delta_time = delta_time
await sse.emit("scene_complete", { await sse.emit("scene_complete", {

View File

@@ -23,7 +23,7 @@ _logger = get_logger(__name__)
class EntityCreateTool(Tool): class EntityCreateTool(Tool):
name = "entity_create" name = "entity_create"
category = "game" category = "game"
stages = {"world_builder", "world_editor", "orchestrator_phase1", "subagent", "intro_scene"} stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor", "orchestrator_phase1", "subagent", "intro_scene"}
description = ( description = (
"Create a new entity in the current world. The entity_type must exist in " "Create a new entity in the current world. The entity_type must exist in "
"world.schemas. The data must conform to the schema for that type." "world.schemas. The data must conform to the schema for that type."
@@ -800,7 +800,7 @@ class RagAddTool(Tool):
class SubmitPlanTool(Tool): class SubmitPlanTool(Tool):
name = "submit_plan" name = "submit_plan"
category = "game" category = "game"
stages = {"world_builder", "orchestrator_phase1"} stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "orchestrator_phase1"}
description = "End Phase 1. Pass the plan + summary to Phase 2 writer." description = "End Phase 1. Pass the plan + summary to Phase 2 writer."
parameters_schema = { parameters_schema = {
"type": "object", "type": "object",
@@ -884,7 +884,7 @@ class SuggestActionsTool(Tool):
class AskUserTool(Tool): class AskUserTool(Tool):
name = "ask_user" name = "ask_user"
category = "interaction" category = "interaction"
stages = {"world_builder", "world_editor"} stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
description = "Ask the player a clarification question. Blocks until answer." description = "Ask the player a clarification question. Blocks until answer."
parameters_schema = { parameters_schema = {
"type": "object", "type": "object",
@@ -947,7 +947,7 @@ class ProposeChangesTool(Tool):
class CommentToUserTool(Tool): class CommentToUserTool(Tool):
name = "comment_to_user" name = "comment_to_user"
category = "interaction" category = "interaction"
stages = {"world_builder", "world_editor"} stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
description = "Send a text comment to the user (no answer expected)." description = "Send a text comment to the user (no answer expected)."
parameters_schema = { parameters_schema = {
"type": "object", "type": "object",

View File

@@ -17,7 +17,7 @@ def _find_schema(world_schemas: list[dict], type_name: str) -> dict | None:
class SchemaAddTypeTool(Tool): class SchemaAddTypeTool(Tool):
name = "schema_add_type" name = "schema_add_type"
category = "schema" category = "schema"
stages = {"world_builder", "world_editor"} stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
description = "Add a new entity type to world.schemas." description = "Add a new entity type to world.schemas."
parameters_schema = { parameters_schema = {
"type": "object", "type": "object",
@@ -52,7 +52,7 @@ class SchemaAddTypeTool(Tool):
class SchemaAddFieldTool(Tool): class SchemaAddFieldTool(Tool):
name = "schema_add_field" name = "schema_add_field"
category = "schema" category = "schema"
stages = {"world_builder", "world_editor"} stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
description = "Add a field to an existing entity type." description = "Add a field to an existing entity type."
parameters_schema = { parameters_schema = {
"type": "object", "type": "object",
@@ -86,7 +86,7 @@ class SchemaAddFieldTool(Tool):
class SchemaRemoveFieldTool(Tool): class SchemaRemoveFieldTool(Tool):
name = "schema_remove_field" name = "schema_remove_field"
category = "schema" category = "schema"
stages = {"world_builder", "world_editor"} stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
description = "Remove a field from an entity type." description = "Remove a field from an entity type."
parameters_schema = { parameters_schema = {
"type": "object", "type": "object",
@@ -115,7 +115,7 @@ class SchemaRemoveFieldTool(Tool):
class SchemaModifyFieldTool(Tool): class SchemaModifyFieldTool(Tool):
name = "schema_modify_field" name = "schema_modify_field"
category = "schema" category = "schema"
stages = {"world_builder", "world_editor"} stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
description = "Modify an existing field of an entity type." description = "Modify an existing field of an entity type."
parameters_schema = { parameters_schema = {
"type": "object", "type": "object",

View File

@@ -2,11 +2,15 @@
Flow (see §9.1 of TDD): Flow (see §9.1 of TDD):
1. Receive template (preset or form). 1. Receive template (preset or form).
2. Generate schemas + environment_schema + rules + time_schema. 2. Generate schemas + environment_schema + rules + time_schema (via tools or preset).
3. Generate initial environment (player + current_location + plot_rails). 3. Generate initial environment (player + current_location + plot_rails) via tools.
4. Generate initial entities (locations, NPCs, items). 4. Generate initial entities (locations, NPCs, items) via entity_create tool.
5. Generate intro scene + suggested actions. 5. Generate intro scene + suggested actions.
6. Mark world status='ready'. 6. Mark world status='ready'.
Resumability: each stage checks if the world already has the needed data
and skips if so. This allows re-running the builder after a failure at any
stage without redoing earlier stages.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,12 +19,13 @@ import json
import uuid import uuid
from typing import Any from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient, MockLlmClient from app.core.llm import LlmClient, MockLlmClient
from app.core.logging import get_logger from app.core.logging import get_logger
from app.core.state_validator import validate_world from app.core.state_validator import validate_world
from app.core.time_utils import summarize_schemas from app.core.time_utils import advance_time, summarize_schemas
from app.engine.sse import SseEmitter from app.engine.sse import SseEmitter
from app.engine.tools.base import ToolContext, get_registry from app.engine.tools.base import ToolContext, get_registry
from app.models import World, WorldPreset from app.models import World, WorldPreset
@@ -41,93 +46,80 @@ async def run_world_builder(
) -> None: ) -> None:
"""Run the full world_builder flow for a draft world. """Run the full world_builder flow for a draft world.
Emits SSE events and updates the world row in place. On error, emits `error` Each stage is resumable: if the world already has the data from a previous
and returns (the world stays in status='draft'). run (e.g. schemas exist), that stage is skipped.
""" """
try: try:
# ---- Step 1: Generate schemas / rules / time_schema / environment_schema # ============ Stage 1: Schemas ============
# If preset provided AND world already has schemas (from preset), skip.
# If no preset, generate schemas via LLM tool-calling.
if not world.schemas:
await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."}) await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."})
schema_prompt = get_prompt("world_builder_schema", "en").format(
mode="preset" if preset else "form",
form_data=json.dumps({}, ensure_ascii=False),
preset_name=preset.name if preset else "",
player_name=player_name,
language=world.language,
notes=notes or "",
)
# If we have a preset, use its schemas directly instead of calling LLM
if preset and preset.schemas: if preset and preset.schemas:
# Use preset schemas directly
world.schemas = preset.schemas world.schemas = preset.schemas
world.environment_schema = preset.environment_schema world.environment_schema = preset.environment_schema
world.rules = preset.rules world.rules = preset.rules
world.time_schema = preset.time_schema world.time_schema = preset.time_schema
world.environment = dict(preset.environment_initial) await db.commit()
else: else:
resp = await llm.complete( # Generate via LLM using schema_add_type tool
stage="world_builder_schema", ok = await _generate_schemas_via_tools(
messages=[{"role": "system", "content": schema_prompt}], db=db, world=world, llm=llm, sse=sse,
temperature=0.5, player_name=player_name, notes=notes,
max_tokens=4096,
world_id=world.id,
session=db,
) )
try: if not ok:
content = resp["message"].get("content", "") await sse.error("schema_generation_failed", "Failed to generate schemas")
# Strip markdown fences if present
content = _strip_code_fence(content)
schema_data = json.loads(content)
except (json.JSONDecodeError, KeyError) as e:
await sse.error("schema_generation_failed", f"Invalid JSON from LLM: {e}")
return return
world.schemas = schema_data.get("schemas", []) await sse.emit("world_schema_generated", {
world.environment_schema = schema_data.get("environment_schema", []) "schemas": world.schemas, "environment_schema": world.environment_schema,
world.rules = schema_data.get("rules", []) })
world.time_schema = schema_data.get("time_schema", {"hours_in_day": 24, "initial_date": "day_1_hour_8"}) else:
world.environment = schema_data.get("environment_initial", {}) await sse.emit("step", {"step": "skipping_schema", "message": "Schemas already exist, skipping..."})
# ============ Stage 2: Environment ============
# Ensure player name is set # Ensure player name is set
env = dict(world.environment or {}) env = dict(world.environment or {})
if isinstance(env.get("player"), dict): if isinstance(env.get("player"), dict):
env["player"]["name"] = player_name env["player"]["name"] = player_name
else: else:
env["player"] = {"name": player_name} # If no player, create a minimal one
env["player"] = {"name": player_name, "stats": {"health": 100}}
world.environment = env world.environment = env
await db.commit()
await sse.emit("world_schema_generated", {
"schemas": world.schemas, "environment_schema": world.environment_schema,
})
# ---- Step 2: Generate environment (skip if preset provided one) # Check if environment has current_location and plot_rails
if not preset or not preset.environment_initial: needs_env = (
not env.get("current_location")
or not env.get("plot_rails")
or not (env.get("plot_rails") or {}).get("hooks")
)
if needs_env and not (preset and preset.environment_initial and env.get("current_location")):
await sse.emit("step", {"step": "generating_environment", "message": "Generating environment..."}) await sse.emit("step", {"step": "generating_environment", "message": "Generating environment..."})
env_prompt = get_prompt("world_builder_env", "en").format( if preset and preset.environment_initial and not env.get("current_location"):
world_name=world.name, # Use preset environment but ensure player name
world_description=world.description or "", preset_env = dict(preset.environment_initial)
language=world.language, if isinstance(preset_env.get("player"), dict):
rules="\n".join(f"- {r}" for r in (world.rules or [])), preset_env["player"]["name"] = player_name
schemas_summary=summarize_schemas(world.schemas or []), world.environment = preset_env
environment_schema_json=json.dumps(world.environment_schema, ensure_ascii=False, indent=2),
player_name=player_name,
)
resp = await llm.complete(
stage="world_builder_env",
messages=[{"role": "system", "content": env_prompt}],
temperature=0.6,
max_tokens=2048,
world_id=world.id,
session=db,
)
try:
content = _strip_code_fence(resp["message"].get("content", ""))
env_data = json.loads(content)
env_data.setdefault("player", {}).setdefault("name", player_name)
world.environment = env_data
except (json.JSONDecodeError, KeyError) as e:
await sse.error("env_generation_failed", f"Invalid env JSON: {e}")
return
await db.commit() await db.commit()
else:
# Generate via LLM using env_update tool
ok = await _generate_environment_via_tools(
db=db, world=world, llm=llm, sse=sse, player_name=player_name,
)
if not ok:
await sse.error("env_generation_failed", "Failed to generate environment")
return
await sse.emit("environment_generated", {"environment": world.environment}) await sse.emit("environment_generated", {"environment": world.environment})
else:
# Ensure plot_rails exists (duplicate to world.plot_rails column)
pr = (world.environment or {}).get("plot_rails")
if pr:
world.plot_rails = pr
await db.commit()
await sse.emit("step", {"step": "skipping_environment", "message": "Environment already set, skipping..."})
# Validate world # Validate world so far
ok, errors = validate_world({ ok, errors = validate_world({
"name": world.name, "language": world.language, "name": world.name, "language": world.language,
"schemas": world.schemas, "environment_schema": world.environment_schema, "schemas": world.schemas, "environment_schema": world.environment_schema,
@@ -135,10 +127,25 @@ async def run_world_builder(
"current_time": world.current_time, "current_time": world.current_time,
}) })
if not ok: if not ok:
await sse.error("world_invalid", "World validation failed", details=errors) # Don't fail — log and continue, the world may still be usable
return _logger.warning("world_validation_partial", world_id=str(world.id), errors=errors)
await sse.emit("warning", {
"code": "validation_warnings",
"message": "World has validation issues: " + "; ".join(errors[:3]),
})
# ---- Step 3: Generate entities via tool-calling loop # ============ Stage 3: Entities ============
# Check if world already has entities
from app.models import Entity
existing_entities = (
await db.execute(
select(Entity).where(
Entity.world_id == world.id, Entity.deleted_at.is_(None)
).limit(1)
)
).scalars().first()
if not existing_entities:
await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."}) await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."})
await _run_tool_loop( await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse, db=db, world=world, llm=llm, sse=sse,
@@ -149,21 +156,20 @@ async def run_world_builder(
language=world.language, language=world.language,
schemas_summary=summarize_schemas(world.schemas or []), schemas_summary=summarize_schemas(world.schemas or []),
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
max_substeps=12, max_substeps=15,
), ),
terminal_tool="submit_plan", terminal_tool="submit_plan",
max_substeps=12, max_substeps=15,
settings={}, # world_builder uses fixed defaults settings={},
) )
await db.commit() await db.commit()
await sse.emit("entities_generated", {"world_id": str(world.id)}) await sse.emit("entities_generated", {"world_id": str(world.id)})
else:
await sse.emit("step", {"step": "skipping_entities", "message": "Entities already exist, skipping..."})
# ---- Step 4: Generate intro scene # ============ Stage 4: Intro scene ============
if not world.intro_scene:
await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."}) await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
from sqlalchemy import select
from app.models import Entity
entities = ( entities = (
await db.execute( await db.execute(
select(Entity).where( select(Entity).where(
@@ -173,21 +179,19 @@ async def run_world_builder(
).scalars().all() ).scalars().all()
entities_summary = "\n".join( entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities[:20] f"- {e.entity_type}: {e.name}" for e in entities[:20]
) ) or "(no entities)"
intro_prompt = get_prompt("intro_scene", "en").format( scene_result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="intro_scene",
system_prompt=get_prompt("intro_scene", "en").format(
world_name=world.name, world_name=world.name,
world_description=world.description or "", world_description=world.description or "",
language=world.language, language=world.language,
current_time=world.current_time, current_time=world.current_time,
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2), environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
plot_rails_json=json.dumps(world.plot_rails, ensure_ascii=False, indent=2), plot_rails_json=json.dumps(world.plot_rails or {}, ensure_ascii=False, indent=2),
entities_summary=entities_summary, entities_summary=entities_summary,
) ),
# Phase 2: scene_text + delta_time
scene_result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="intro_scene",
system_prompt=intro_prompt,
terminal_tool="submit_step", terminal_tool="submit_step",
max_substeps=3, max_substeps=3,
settings={}, settings={},
@@ -197,14 +201,19 @@ async def run_world_builder(
if scene_result and scene_result.get("ok"): if scene_result and scene_result.get("ok"):
scene_text = scene_result.get("data", {}).get("scene_text", "") scene_text = scene_result.get("data", {}).get("scene_text", "")
delta_time = scene_result.get("data", {}).get("delta_time", "hours_1") delta_time = scene_result.get("data", {}).get("delta_time", "hours_1")
# Apply text replacements
if scene_text:
from app.core.settings_service import apply_text_replacements
scene_text = await apply_text_replacements(db, scene_text)
if scene_text:
world.intro_scene = scene_text world.intro_scene = scene_text
from app.core.time_utils import advance_time
world.current_time = advance_time(world.current_time, delta_time, world.time_schema) world.current_time = advance_time(world.current_time, delta_time, world.time_schema)
await db.commit() await db.commit()
await sse.emit("intro_scene_complete", { await sse.emit("intro_scene_complete", {
"text": scene_text, "delta_time": delta_time, "current_time": world.current_time, "text": scene_text, "delta_time": delta_time, "current_time": world.current_time,
}) })
else:
await sse.emit("step", {"step": "skipping_intro", "message": "Intro scene already exists, skipping..."})
# Mark ready # Mark ready
world.status = "ready" world.status = "ready"
@@ -215,11 +224,76 @@ async def run_world_builder(
await sse.error("internal_error", str(e)) await sse.error("internal_error", str(e))
async def _generate_schemas_via_tools(
*,
db: AsyncSession,
world: World,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
player_name: str,
notes: str | None,
) -> bool:
"""Generate world schemas by having the LLM call schema_add_type tools."""
prompt = get_prompt("world_builder_schema", "en").format(
mode="form",
form_data="{}",
preset_name="",
player_name=player_name,
language=world.language,
notes=notes or "",
)
result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="world_builder_schema",
system_prompt=prompt,
terminal_tool="submit_plan",
max_substeps=10,
settings={},
)
# After tool loop, check if schemas were created
await db.refresh(world)
return bool(world.schemas)
async def _generate_environment_via_tools(
*,
db: AsyncSession,
world: World,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
player_name: str,
) -> bool:
"""Generate environment by having the LLM call env_update tools."""
prompt = get_prompt("world_builder_env", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
rules="\n".join(f"- {r}" for r in (world.rules or [])),
schemas_summary=summarize_schemas(world.schemas or []),
environment_schema_json=json.dumps(world.environment_schema, ensure_ascii=False, indent=2),
player_name=player_name,
)
result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="world_builder_env",
system_prompt=prompt,
terminal_tool="submit_plan",
max_substeps=10,
settings={},
)
await db.refresh(world)
env = world.environment or {}
# Sync plot_rails
if isinstance(env.get("plot_rails"), dict):
world.plot_rails = env["plot_rails"]
await db.commit()
return bool(env.get("current_location"))
def _strip_code_fence(text: str) -> str: def _strip_code_fence(text: str) -> str:
"""Remove ```json ... ``` fences if present.""" """Remove ```json ... ``` fences if present."""
s = text.strip() s = text.strip()
if s.startswith("```"): if s.startswith("```"):
# Remove first line (``` or ```json)
s = s.split("\n", 1)[1] if "\n" in s else s s = s.split("\n", 1)[1] if "\n" in s else s
if s.endswith("```"): if s.endswith("```"):
s = s[:-3] s = s[:-3]
@@ -246,7 +320,7 @@ async def _run_tool_loop(
) )
messages: list[dict[str, Any]] = [ messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": f"Begin {stage}."}, {"role": "user", "content": f"Begin {stage}. Use the available tools to accomplish the task. When done, call {terminal_tool}."},
] ]
tools = registry.to_openai_format(stage) tools = registry.to_openai_format(stage)
last_terminal_result: dict[str, Any] | None = None last_terminal_result: dict[str, Any] | None = None
@@ -273,20 +347,19 @@ async def _run_tool_loop(
messages.append({"role": "assistant", "content": msg.get("content", "")}) messages.append({"role": "assistant", "content": msg.get("content", "")})
messages.append({ messages.append({
"role": "user", "role": "user",
"content": "You must call a tool. Available terminal tool: " + terminal_tool, "content": f"You must call a tool. Available terminal tool: {terminal_tool}. If you are done with your work, call {terminal_tool} now.",
}) })
continue continue
messages.append(msg) messages.append(msg)
for tc in tool_calls: for tc in tool_calls:
fn = tc.get("function", {}) fn = tc.get("function", {}) if isinstance(tc, dict) else {}
tname = fn.get("name", "") tname = fn.get("name", "")
try: try:
targs = json.loads(fn.get("arguments") or "{}") targs = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError: except json.JSONDecodeError:
targs = {} targs = {}
result = await registry.execute(tname, targs, ctx) result = await registry.execute(tname, targs, ctx)
# Tool result as a tool message
messages.append({ messages.append({
"role": "tool", "role": "tool",
"tool_call_id": tc.get("id", ""), "tool_call_id": tc.get("id", ""),

View File

@@ -121,7 +121,6 @@ async def run_world_editor(
for tc in tcs: for tc in tcs:
fn = tc.get("function") or {} fn = tc.get("function") or {}
if not isinstance(fn, dict): if not isinstance(fn, dict):
# Some models put the function name/args directly on tc
fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")} fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")}
tname = fn.get("name", "") tname = fn.get("name", "")
args_str = fn.get("arguments", "{}") args_str = fn.get("arguments", "{}")
@@ -136,7 +135,6 @@ async def run_world_editor(
"question": targs.get("question", ""), "question": targs.get("question", ""),
"options": targs.get("options"), "options": targs.get("options"),
}) })
# Wait for user answer via REST
fut: asyncio.Future[str] = asyncio.get_event_loop().create_future() fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
_pending_clarifications[world.id] = fut _pending_clarifications[world.id] = fut
try: try:
@@ -161,7 +159,6 @@ async def run_world_editor(
"diff": diff, "diff": diff,
"comment": comment, "comment": comment,
}) })
# Wait for user accept/reject via REST
fut2: asyncio.Future[bool] = asyncio.get_event_loop().create_future() fut2: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
_pending_changes[world.id] = fut2 _pending_changes[world.id] = fut2
try: try:
@@ -176,21 +173,12 @@ async def run_world_editor(
await _apply_diff(world, diff) await _apply_diff(world, diff)
await db.commit() await db.commit()
await sse.emit("apply_changes", {"diff": diff}) await sse.emit("apply_changes", {"diff": diff})
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": tname,
"content": json.dumps({"ok": True, "applied": True}),
})
else: else:
await sse.emit("discard_changes", {}) await sse.emit("discard_changes", {})
messages.append({ # After propose_changes (accepted or rejected), END the loop.
"role": "tool", # Don't make another LLM call — the user's decision is final.
"tool_call_id": tc.get("id", ""), done = True
"name": tname, break
"content": json.dumps({"ok": True, "applied": False, "reason": "user_rejected"}),
})
continue
# Regular tool — execute # Regular tool — execute
result = await registry.execute(tname, targs, ctx) result = await registry.execute(tname, targs, ctx)
messages.append({ messages.append({

View File

@@ -1,21 +1,31 @@
"""System prompt for stage `world_builder_env` — generates the initial environment.""" """System prompt for stage `world_builder_env` — generates the initial environment via env_update tool."""
PROMPTS = { PROMPTS = {
"en": """You are the World Builder for an AI-driven text RPG. "en": """You are the World Builder for an AI-driven text RPG.
Your task: produce the initial `environment` JSON for a world whose schema has Your task: set up the initial environment for the world by calling the `env_update` tool.
already been generated.
The environment must include: The environment must include:
- "player": a character object matching the `character` schema. The player's name is - `player`: a character object. The player's name is `{player_name}`. Give them starting stats (health=100, mana=10, strength=10), an empty inventory, and a short backstory (1-2 sentences).
`{player_name}`. Give them starting stats (health=100, mana=10, strength=10), - `current_location`: a string naming the starting location (e.g. "The Rusty Tankard Tavern" or "Station Command Module").
an empty inventory, and a short backstory (1-2 sentences). - `plot_rails`: an object with `hooks` (array of 2 short story hooks), `current_goals` (array of 1 starting goal), and `completed_goals` (empty array).
- "current_location": a string naming the starting location (it must match the
name of one of the locations generated in the next step — for now just pick a # How to use env_update
thematic starting place like "Tavern" or "Camp"). Call env_update with a `patch` argument. The patch is a dict of field_path -> value.
- "plot_rails": {{"hooks": [<2 short story hooks>], "current_goals": [<1 starting goal>], To set nested fields, use dotted paths.
"completed_goals": []}}
- Any other fields declared in environment_schema. Example:
```json
{{
"patch": {{
"player": {{"name": "{player_name}", "stats": {{"health": 100, "mana": 10, "strength": 10}}, "inventory": [], "backstory": "..."}},
"current_location": "Tavern",
"plot_rails": {{"hooks": ["hook1", "hook2"], "current_goals": ["goal1"], "completed_goals": []}}
}}
}}
```
You can either set everything in one env_update call, or make multiple calls (one for player, one for current_location, one for plot_rails).
# World context # World context
World name: {world_name} World name: {world_name}
@@ -30,7 +40,12 @@ Schemas:
Environment schema: Environment schema:
{environment_schema_json} {environment_schema_json}
# Output # After setting up the environment
Return ONLY a JSON object. No commentary. The output must conform to environment_schema. Call `submit_plan` with a 1-sentence summary.
# Rules
- Use env_update to set the environment fields.
- After all env_update calls, call submit_plan exactly once.
- After max 10 tool calls you MUST call submit_plan.
""", """,
} }

View File

@@ -1,33 +1,9 @@
"""System prompt for stage `world_builder_schema` — generates the world's schemas.""" """System prompt for stage `world_builder_schema` — generates the world's schemas via tools."""
PROMPTS = { PROMPTS = {
"en": """You are the World Builder for an AI-driven text RPG. "en": """You are the World Builder for an AI-driven text RPG.
Your task: produce the JSON schema for a new world based on the player's request. Your task: define the entity schemas for a new world by calling the `schema_add_type` tool for each entity type you want to create.
Output a JSON object with keys:
- "name": short world name
- "description": 2-3 sentence world premise
- "language": ISO code (e.g. "en", "ru") — must match the player's requested language
- "rules": array of short rule strings the GM must follow
- "time_schema": {{"hours_in_day": 24, "initial_date": "day_1_hour_8"}}
- "schemas": array of entity-type definitions, each shaped as
{{"type": "character", "verbose": "Character", "plural": "characters",
"properties": [
{{"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": "strength","type": "integer", "required": true, "min": 1, "max": 20}}
]}}
]}}
Include at minimum: character (with stats.health, stats.mana, stats.strength,
inventory array of items), item, location, faction.
- "environment_schema": array of top-level environment fields
(e.g. player:object, current_location:string, plot_rails:object)
- "environment_initial": initial environment JSON (with player empty, current_location empty,
plot_rails with empty arrays)
# Player request # Player request
Mode: {mode} Mode: {mode}
@@ -37,12 +13,40 @@ Player name: {player_name}
Language: {language} Language: {language}
Notes: {notes} Notes: {notes}
# Rules for output # Required entity types
- Return ONLY a JSON object. No commentary. You MUST create at minimum these entity types (call schema_add_type for each):
- Keep schemas small (3-6 fields per type). 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)
- "stats.health" must be integer with min=0 max=100. 2. `item` — with fields: name (string, required), description (string), qty (integer 1-9999), value (integer)
- Always include `player` (character) and `current_location` (string) in environment_schema. 3. `location` — with fields: name (string, required), description (string, required), exits (array of strings), is_safe (boolean)
- The world is for a 7B-parameter LLM — keep schemas readable. 4. `faction` — with 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:
- `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}
Example properties 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}}
]}}
]
```
# After creating all schemas
Call `submit_plan` with a 1-sentence summary of the world you designed.
# Rules
- 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.
""", """,
"ru": "", # legacy — English is the source of truth per §10.1 "ru": "",
} }

View File

@@ -15,6 +15,7 @@ import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
import { LoginPage } from "@/pages/LoginPage"; import { LoginPage } from "@/pages/LoginPage";
import { RegisterPage } from "@/pages/RegisterPage"; import { RegisterPage } from "@/pages/RegisterPage";
import { AdminRegisterPage } from "@/pages/AdminRegisterPage"; import { AdminRegisterPage } from "@/pages/AdminRegisterPage";
import { AdminRecoverPage } from "@/pages/AdminRecoverPage";
import { WorldsListPage } from "@/pages/WorldsListPage"; import { WorldsListPage } from "@/pages/WorldsListPage";
import { WorldBuilderPage } from "@/pages/WorldBuilderPage"; import { WorldBuilderPage } from "@/pages/WorldBuilderPage";
import { WorldEditPage } from "@/pages/WorldEditPage"; import { WorldEditPage } from "@/pages/WorldEditPage";
@@ -109,6 +110,16 @@ export default function App() {
</PublicOnly> </PublicOnly>
} }
/> />
{/* Disaster-recovery admin creation — PUBLIC (no auth), not behind
ProtectedRoute. Used when all admins have lost access. */}
<Route
path="/recover"
element={
<Layout>
<AdminRecoverPage />
</Layout>
}
/>
{/* Protected routes */} {/* Protected routes */}
<Route <Route

View File

@@ -92,6 +92,11 @@ export function LlmLogsTable() {
} }
}; };
// Short preview of the world_id filter value, for the column header.
const worldFilterPreview = appliedFilters.world_id
? appliedFilters.world_id.slice(0, 8)
: "";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card title={t("admin.tab_logs")}> <Card title={t("admin.tab_logs")}>
@@ -149,6 +154,14 @@ export function LlmLogsTable() {
<thead> <thead>
<tr className="border-b border-fg-dim/20 text-left text-xs uppercase text-fg-muted"> <tr className="border-b border-fg-dim/20 text-left text-xs uppercase text-fg-muted">
<th className="p-2">{t("admin.logs_stage")}</th> <th className="p-2">{t("admin.logs_stage")}</th>
<th className="p-2">
{t("admin.logs_world")}
{worldFilterPreview && (
<span className="ml-1 normal-case text-fg-dim">
({t("admin.logs_filtered")}: {worldFilterPreview})
</span>
)}
</th>
<th className="p-2">{t("admin.logs_status")}</th> <th className="p-2">{t("admin.logs_status")}</th>
<th className="p-2">{t("admin.logs_latency")}</th> <th className="p-2">{t("admin.logs_latency")}</th>
<th className="p-2">{t("admin.logs_tokens")}</th> <th className="p-2">{t("admin.logs_tokens")}</th>
@@ -160,6 +173,9 @@ export function LlmLogsTable() {
{data.items.map((log) => ( {data.items.map((log) => (
<tr key={log.id} className="border-b border-fg-dim/10 hover:bg-bg-soft"> <tr key={log.id} className="border-b border-fg-dim/10 hover:bg-bg-soft">
<td className="p-2 font-mono text-xs">{log.stage}</td> <td className="p-2 font-mono text-xs">{log.stage}</td>
<td className="p-2 font-mono text-xs text-fg-muted">
{log.world_id ? log.world_id.slice(0, 8) : "—"}
</td>
<td className="p-2"> <td className="p-2">
<span className={`badge ${statusColor(log.status)}`}> <span className={`badge ${statusColor(log.status)}`}>
{log.status} {log.status}
@@ -230,6 +246,7 @@ export function LlmLogsTable() {
badgeClass={statusColor(detail.status)} badgeClass={statusColor(detail.status)}
/> />
<Field label={t("admin.model")} value={detail.model || "—"} /> <Field label={t("admin.model")} value={detail.model || "—"} />
<Field label={t("admin.logs_world")} value={detail.world_id || "—"} />
<Field <Field
label={t("admin.logs_latency")} label={t("admin.logs_latency")}
value={detail.latency_ms != null ? `${detail.latency_ms} ms` : "—"} value={detail.latency_ms != null ? `${detail.latency_ms} ms` : "—"}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { Fragment, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AdminApi } from "@/lib/api"; import { AdminApi, ApiError } from "@/lib/api";
import { useToastStore } from "@/stores/toastStore"; import { useToastStore } from "@/stores/toastStore";
import { refreshUiSettings } from "@/stores/uiSettingsStore"; import { refreshUiSettings } from "@/stores/uiSettingsStore";
import type { AdminSettingsResponse } from "@/types"; import type { AdminSettingsResponse } from "@/types";
@@ -33,6 +33,11 @@ function groupFor(key: string): GroupDef | null {
return null; return null;
} }
/** Setting keys rendered with a specialized UI rather than the generic
* text/integer/boolean row. They are filtered out of the regular field
* list and rendered separately. */
const SPECIAL_KEYS = new Set<string>(["llm.model", "llm.text_replacements"]);
/** Field types — drives which control is rendered. */ /** Field types — drives which control is rendered. */
type FieldType = "integer" | "float" | "boolean" | "provider" | "secret" | "text"; type FieldType = "integer" | "float" | "boolean" | "provider" | "secret" | "text";
@@ -143,6 +148,9 @@ export function SettingsPanel() {
for (const key of Object.keys(data.settings)) { for (const key of Object.keys(data.settings)) {
const g = groupFor(key); const g = groupFor(key);
if (!g) continue; if (!g) continue;
// Special-rendered keys are filtered out of the regular list — they
// get their own dedicated UI (model fetcher, text replacements).
if (SPECIAL_KEYS.has(key)) continue;
(out[g.id] ||= []).push({ key, description: data.descriptions?.[key] }); (out[g.id] ||= []).push({ key, description: data.descriptions?.[key] });
} }
// Sort each group's keys alphabetically for stable display. // Sort each group's keys alphabetically for stable display.
@@ -199,6 +207,30 @@ export function SettingsPanel() {
} }
}; };
/**
* Save a single setting key (used by the Text Replacements card, which
* saves only `llm.text_replacements`). No casting is applied — the value
* is stored as-is (a JSON string).
*/
const handleSaveKey = async (key: string, value: string) => {
if (!data) return;
const before = data.settings[key] ?? "";
if (before === value) {
pushToast("info", "No changes to save.");
return;
}
try {
const res = await AdminApi.updateSettings({ [key]: value });
const nextSettings = { ...data.settings, [key]: res.updated[key] };
setData({ ...data, settings: nextSettings });
setDraft((d) => ({ ...d, [key]: res.updated[key] }));
pushToast("success", t("admin.settings_saved"));
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save settings";
pushToast("error", msg);
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
@@ -236,18 +268,30 @@ export function SettingsPanel() {
</div> </div>
{GROUPS.map((g) => { {GROUPS.map((g) => {
const entries = grouped[g.id]; const entries = grouped[g.id];
if (!entries || entries.length === 0) return null; // LLM group always renders even if api_url/api_key/etc. are missing
// from the backend response — the specialized sub-cards (model
// fetcher, text replacements) live here. For other groups, skip
// when empty.
if ((!entries || entries.length === 0) && g.id !== "llm") return null;
return ( return (
<Fragment key={g.id}>
<SettingsGroupCard <SettingsGroupCard
key={g.id} groupId={g.id}
title={t(g.labelKey)} title={t(g.labelKey)}
entries={entries} entries={entries || []}
draft={draft} draft={draft}
onChange={(key, value) => onChange={(key, value) =>
setDraft((d) => ({ ...d, [key]: value })) setDraft((d) => ({ ...d, [key]: value }))
} }
onSave={() => void handleSaveGroup(g.id)} onSave={() => void handleSaveGroup(g.id)}
/> />
{g.id === "llm" && (
<TextReplacementsCard
rawValue={draft["llm.text_replacements"] ?? ""}
onSave={(v) => void handleSaveKey("llm.text_replacements", v)}
/>
)}
</Fragment>
); );
})} })}
</div> </div>
@@ -255,6 +299,7 @@ export function SettingsPanel() {
} }
interface SettingsGroupCardProps { interface SettingsGroupCardProps {
groupId: string;
title: string; title: string;
entries: Array<{ key: string; description?: string }>; entries: Array<{ key: string; description?: string }>;
draft: Record<string, string>; draft: Record<string, string>;
@@ -262,7 +307,7 @@ interface SettingsGroupCardProps {
onSave: () => void; onSave: () => void;
} }
function SettingsGroupCard({ title, entries, draft, onChange, onSave }: SettingsGroupCardProps) { function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const handleSave = async () => { const handleSave = async () => {
@@ -273,6 +318,17 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
setSaving(false); setSaving(false);
} }
}; };
// The "llm.model" key is rendered as a full-width specialized field with
// a "Fetch models" button — pull it out of the regular grid flow when
// present so it can span both columns.
const modelEntry = groupId === "llm"
? (entries.find((e) => e.key === "llm.model") ?? (draft["llm.model"] !== undefined ? { key: "llm.model" } : null))
: null;
const regularEntries = entries.filter((e) => e.key !== "llm.model");
// Always render the LLM group even if only `llm.model` is present, since
// the model field is special.
const showGrid = regularEntries.length > 0;
void groupId; // groupId currently used only for the model-entry lookup above
return ( return (
<Card <Card
title={title} title={title}
@@ -282,8 +338,10 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
</Button> </Button>
} }
> >
<div className="space-y-3">
{showGrid && (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{entries.map(({ key, description }) => ( {regularEntries.map(({ key, description }) => (
<SettingField <SettingField
key={key} key={key}
settingKey={key} settingKey={key}
@@ -293,6 +351,17 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
/> />
))} ))}
</div> </div>
)}
{modelEntry && (
<LlmModelField
value={draft["llm.model"] ?? ""}
onChange={(v) => onChange("llm.model", v)}
apiUrl={draft["llm.api_url"] ?? ""}
apiKey={draft["llm.api_key"] ?? ""}
description={modelEntry.description}
/>
)}
</div>
</Card> </Card>
); );
} }
@@ -411,3 +480,245 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
/> />
); );
} }
// ============================================================================
// LLM model field — text input + "Fetch models" button + dropdown.
// ============================================================================
interface LlmModelFieldProps {
value: string;
onChange: (value: string) => void;
apiUrl: string;
apiKey: string;
description?: string;
}
/**
* Specialized renderer for the `llm.model` setting. Shows a text input
* (where the user can type any model name) plus a "Fetch models" button
* that probes the configured provider and, on success, renders a dropdown
* of available models below the input. Selecting from the dropdown fills
* the text input. The fetched list is cached in component state — it is
* only re-fetched when the button is clicked.
*/
function LlmModelField({ value, onChange, apiUrl, apiKey, description }: LlmModelFieldProps) {
const { t } = useTranslation();
const [fetching, setFetching] = useState(false);
const [models, setModels] = useState<string[] | null>(null);
const [fetchError, setFetchError] = useState(false);
const hint = description;
const handleFetch = async () => {
setFetching(true);
setFetchError(false);
try {
const res = await AdminApi.listLlmModels(apiUrl || undefined, apiKey || undefined);
if (res.ok && Array.isArray(res.models)) {
setModels(res.models);
} else {
setModels([]);
setFetchError(true);
}
} catch (err) {
// ApiError carries a message from the backend (e.g. provider down,
// bad key). We don't show it inline — the muted hint is enough —
// but we do log it for debugging.
setModels([]);
setFetchError(true);
if (err instanceof ApiError) {
// eslint-disable-next-line no-console
console.warn("listLlmModels failed:", err.message, err.details);
}
} finally {
setFetching(false);
}
};
return (
<div className="w-full">
<label className="label" htmlFor="setting-llm.model">llm.model</label>
<div className="flex gap-2">
<input
id="setting-llm.model"
className="input flex-1"
autoComplete="off"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="gpt-4o-mini"
/>
<Button
type="button"
size="sm"
variant="secondary"
onClick={handleFetch}
loading={fetching}
disabled={fetching}
className="shrink-0"
>
{t("admin.fetch_models")}
</Button>
</div>
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
{models && models.length > 0 && (
<div className="mt-2">
<label className="label text-xs" htmlFor="llm-model-select">
{t("admin.fetched_models")} ({models.length})
</label>
<select
id="llm-model-select"
className="input"
value=""
onChange={(e) => {
if (e.target.value) onChange(e.target.value);
}}
>
<option value="" disabled>
{t("admin.pick_model")}
</option>
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
)}
{fetchError && (
<p className="mt-1 text-xs text-fg-muted">
{t("admin.fetch_models_failed")}
</p>
)}
</div>
);
}
// ============================================================================
// Text Replacements card.
// ============================================================================
interface TextReplacementRule {
from: string;
to: string;
}
interface TextReplacementsCardProps {
rawValue: string;
onSave: (serializedJson: string) => void;
}
/** Parse the persisted JSON string into a list of rules. Tolerates
* malformed / empty input by returning an empty list. */
function parseReplacements(raw: string): TextReplacementRule[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.map((item): TextReplacementRule | null => {
if (item && typeof item === "object") {
const obj = item as { from?: unknown; to?: unknown };
return {
from: typeof obj.from === "string" ? obj.from : "",
to: typeof obj.to === "string" ? obj.to : "",
};
}
return null;
})
.filter((x): x is TextReplacementRule => x !== null);
} catch {
return [];
}
}
function serializeReplacements(rules: TextReplacementRule[]): string {
return JSON.stringify(rules.map((r) => ({ from: r.from, to: r.to })));
}
function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
const { t } = useTranslation();
// Local working copy — only committed to parent draft when Save is
// clicked. This avoids marking the LLM group as dirty on every keystroke.
const [rules, setRules] = useState<TextReplacementRule[]>(() => parseReplacements(rawValue));
const [saving, setSaving] = useState(false);
// Re-sync from the persisted value if it changes externally (e.g. after
// a successful save the parent passes back the masked value, which for
// this key is the same JSON we just wrote).
useEffect(() => {
setRules(parseReplacements(rawValue));
}, [rawValue]);
const dirty = serializeReplacements(rules) !== rawValue;
const addRule = () => {
setRules((r) => [...r, { from: "", to: "" }]);
};
const removeRule = (idx: number) => {
setRules((r) => r.filter((_, i) => i !== idx));
};
const updateRule = (idx: number, field: "from" | "to", value: string) => {
setRules((r) => r.map((rule, i) => (i === idx ? { ...rule, [field]: value } : rule)));
};
const handleSave = async () => {
setSaving(true);
try {
await onSave(serializeReplacements(rules));
} finally {
setSaving(false);
}
};
return (
<Card
title={t("admin.text_replacements_title")}
description={t("admin.text_replacements_help")}
actions={
<Button size="sm" onClick={handleSave} loading={saving} disabled={!dirty}>
{t("common.save")}
</Button>
}
>
<div className="space-y-2">
{rules.length === 0 && (
<p className="text-xs text-fg-muted">{t("admin.text_replacements_empty")}</p>
)}
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
<input
type="text"
className="input flex-1"
placeholder={t("admin.text_replacements_from")}
value={rule.from}
onChange={(e) => updateRule(idx, "from", e.target.value)}
autoComplete="off"
/>
<span className="text-xs text-fg-muted"></span>
<input
type="text"
className="input flex-1"
placeholder={t("admin.text_replacements_to")}
value={rule.to}
onChange={(e) => updateRule(idx, "to", e.target.value)}
autoComplete="off"
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => removeRule(idx)}
aria-label={t("common.delete")}
>
</Button>
</div>
))}
<Button type="button" size="sm" variant="secondary" onClick={addRule}>
+ {t("admin.text_replacements_add")}
</Button>
</div>
</Card>
);
}

View File

@@ -6,7 +6,10 @@ export interface PhaseProgressProps {
/** Phases that have started (key) with display names. */ /** Phases that have started (key) with display names. */
phases: Array<{ phase: string; name?: string; done?: boolean }>; phases: Array<{ phase: string; name?: string; done?: boolean }>;
currentPhase?: string; currentPhase?: string;
step?: number; /** Step index. Normally numeric, but the world builder also emits
* string stage identifiers like "skipping_schema" when resuming —
* non-numeric values are ignored for the "Step X of Y" display. */
step?: number | string;
totalSteps?: number; totalSteps?: number;
message?: string; message?: string;
className?: string; className?: string;

View File

@@ -201,8 +201,11 @@ export function IntroSceneGenerator({ world, onWorldUpdated }: IntroSceneGenerat
void start(); void start();
}; };
// Hide the generator once the world is ready. // Hide the generator once the world is ready AND has an intro scene.
if (world.status === "ready") return null; // We keep showing it for "ready" worlds that somehow have no intro_scene
// (e.g. legacy data, or intro generation completed but the scene was
// never persisted) so the user can regenerate.
if (world.status === "ready" && world.intro_scene) return null;
const busy = state.phase === "starting" || state.phase === "streaming"; const busy = state.phase === "starting" || state.phase === "streaming";

View File

@@ -21,15 +21,23 @@ import { SseStatus } from "@/components/sessions/SseStatus";
type Mode = "preset" | "form"; type Mode = "preset" | "form";
/** Kind of builder log entry — drives the color used to render it. */
type BuilderLogKind = "info" | "skip" | "error" | "warn";
interface BuilderLogEntry {
text: string;
kind: BuilderLogKind;
}
interface BuilderState { interface BuilderState {
phase: "form" | "building" | "done" | "error"; phase: "form" | "building" | "done" | "error";
currentPhase?: string; currentPhase?: string;
phases: Array<{ phase: string; name?: string; done: boolean }>; phases: Array<{ phase: string; name?: string; done: boolean }>;
step?: number; step?: number | string;
totalSteps?: number; totalSteps?: number;
message?: string; message?: string;
introScene: string; introScene: string;
logs: string[]; logs: BuilderLogEntry[];
sseStatus: "idle" | "connecting" | "open" | "error" | "closed"; sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
} }
@@ -131,7 +139,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
...s, ...s,
phase: "error", phase: "error",
sseStatus: "error", sseStatus: "error",
logs: [...s.logs, `[error] ${d?.message || "Stream error"}`], logs: [...s.logs, { text: `[error] ${d?.message || "Stream error"}`, kind: "error" as const }],
})); }));
pushToast("error", d?.message || t("builder.build_failed")); pushToast("error", d?.message || t("builder.build_failed"));
controllerRef.current?.close(); controllerRef.current?.close();
@@ -139,16 +147,29 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
} }
case "warning": { case "warning": {
const d = event.data as { message?: string }; const d = event.data as { message?: string };
setState((s) => ({ ...s, logs: [...s.logs, `[warn] ${d?.message || ""}`] })); setState((s) => ({ ...s, logs: [...s.logs, { text: `[warn] ${d?.message || ""}`, kind: "warn" as const }] }));
break; break;
} }
case "step": { case "step": {
const d = event.data as { step: number; message: string }; // The `step` field is normally a numeric index, but the builder
// also emits string stage identifiers like "skipping_schema" when
// resuming a partially-built world (those stages already exist
// and are skipped). We render those in a muted blue so they're
// visually distinct from "real" progress steps.
const d = event.data as { step?: number | string; message?: string };
const stepVal = d.step;
const isSkip = typeof stepVal === "string" && stepVal.startsWith("skipping_");
setState((s) => ({ setState((s) => ({
...s, ...s,
step: d.step, step: stepVal,
message: d.message, message: d.message,
logs: [...s.logs, `[${d.step}] ${d.message}`], logs: [
...s.logs,
{
text: `[${stepVal ?? "?"}] ${d.message || ""}`,
kind: isSkip ? ("skip" as const) : ("info" as const),
},
],
})); }));
break; break;
} }
@@ -181,13 +202,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
break; break;
} }
case "world_schema_generated": case "world_schema_generated":
setState((s) => ({ ...s, logs: [...s.logs, t("builder.schema_generated")] })); setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.schema_generated"), kind: "info" as const }] }));
break; break;
case "environment_generated": case "environment_generated":
setState((s) => ({ ...s, logs: [...s.logs, t("builder.environment_generated")] })); setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.environment_generated"), kind: "info" as const }] }));
break; break;
case "entities_generated": case "entities_generated":
setState((s) => ({ ...s, logs: [...s.logs, t("builder.entities_generated")] })); setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.entities_generated"), kind: "info" as const }] }));
break; break;
case "intro_scene_chunk": { case "intro_scene_chunk": {
const d = event.data as { text: string }; const d = event.data as { text: string };
@@ -464,9 +485,24 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
{state.logs.length > 0 && ( {state.logs.length > 0 && (
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2"> <details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2">
<summary className="cursor-pointer text-xs text-fg-muted">Logs ({state.logs.length})</summary> <summary className="cursor-pointer text-xs text-fg-muted">Logs ({state.logs.length})</summary>
<pre className="mt-2 max-h-48 overflow-auto text-[10px] text-fg-dim"> <div className="mt-2 max-h-48 overflow-auto font-mono text-[10px] leading-relaxed">
{state.logs.join("\n")} {state.logs.map((entry, i) => (
</pre> <div
key={i}
className={cn(
"whitespace-pre-wrap break-words",
entry.kind === "error" && "text-err",
entry.kind === "warn" && "text-warn",
// Skipped stages (resumable builder) get a muted blue
// so they're visually distinct from real progress.
entry.kind === "skip" && "text-sky-500 dark:text-sky-400",
entry.kind === "info" && "text-fg-dim",
)}
>
{entry.text}
</div>
))}
</div>
</details> </details>
)} )}
{state.phase === "done" && ( {state.phase === "done" && (

View File

@@ -45,6 +45,9 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
const isArchived = world.status === "archived"; const isArchived = world.status === "archived";
const isDraft = world.status === "draft"; const isDraft = world.status === "draft";
const isAdmin = !!user?.is_admin; const isAdmin = !!user?.is_admin;
// Prefer the human-readable time string ("Day 1, 08:00"); fall back to the
// raw `current_time` value when the backend doesn't provide the human form.
const displayedTime = world.current_time_human || world.current_time;
const handleRestore = async () => { const handleRestore = async () => {
setRestoring(true); setRestoring(true);
@@ -103,7 +106,7 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
</div> </div>
<div> <div>
<dt className="text-fg-dim">{t("worlds.current_time")}</dt> <dt className="text-fg-dim">{t("worlds.current_time")}</dt>
<dd className="text-fg truncate">{world.current_time || "—"}</dd> <dd className="text-fg truncate">{displayedTime || "—"}</dd>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<dt className="text-fg-dim">{t("worlds.last_played")}</dt> <dt className="text-fg-dim">{t("worlds.last_played")}</dt>

View File

@@ -66,7 +66,14 @@
"register_failed": "Registration failed", "register_failed": "Registration failed",
"session_expired": "Session expired, please sign in again.", "session_expired": "Session expired, please sign in again.",
"username_hint": "Letters, numbers, and underscores only. No @ or other special characters.", "username_hint": "Letters, numbers, and underscores only. No @ or other special characters.",
"username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed." "username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed.",
"recover_title": "Recover admin access",
"recover_help": "Disaster-recovery: creates a new admin account using the setup token. Use this when all admins have lost access.",
"recover_token_hint": "The setup token from the server config (admin.setup_token).",
"recover_button": "Create admin",
"recover_success": "Admin created. You can now log in.",
"recover_failed": "Recovery failed",
"recover_link": "Lost admin access? Recover here"
}, },
"worlds": { "worlds": {
"title": "Your Worlds", "title": "Your Worlds",
@@ -232,6 +239,8 @@
"logs_filter_apply": "Apply filters", "logs_filter_apply": "Apply filters",
"logs_stage": "Stage", "logs_stage": "Stage",
"logs_status": "Status", "logs_status": "Status",
"logs_world": "World",
"logs_filtered": "filtered",
"logs_latency": "Latency", "logs_latency": "Latency",
"logs_tokens": "Tokens", "logs_tokens": "Tokens",
"logs_created": "Created", "logs_created": "Created",
@@ -282,7 +291,17 @@
"tool_calls_detected": "Tool calls detected", "tool_calls_detected": "Tool calls detected",
"no_tool_calls_warning_title": "No tool calls returned", "no_tool_calls_warning_title": "No tool calls returned",
"no_tool_calls_warning": "Model did not return tool calls. This may mean the model doesn't support function calling, or uses a non-standard format.", "no_tool_calls_warning": "Model did not return tool calls. This may mean the model doesn't support function calling, or uses a non-standard format.",
"raw_response": "Raw LLM response" "raw_response": "Raw LLM response",
"fetch_models": "Fetch models",
"fetched_models": "Available models",
"pick_model": "Pick a model…",
"fetch_models_failed": "Could not fetch model list. Enter the model name manually.",
"text_replacements_title": "Text Replacements",
"text_replacements_help": "These replacements are applied to all LLM scene text output. Use empty 'To' to remove a word/phrase entirely.",
"text_replacements_empty": "No replacement rules yet. Click 'Add rule' to create one.",
"text_replacements_from": "From",
"text_replacements_to": "To",
"text_replacements_add": "Add rule"
}, },
"errors": { "errors": {
"generic": "Something went wrong.", "generic": "Something went wrong.",

View File

@@ -66,7 +66,14 @@
"register_failed": "Не удалось зарегистрироваться", "register_failed": "Не удалось зарегистрироваться",
"session_expired": "Сессия истекла, пожалуйста, войдите снова.", "session_expired": "Сессия истекла, пожалуйста, войдите снова.",
"username_hint": "Только буквы, цифры и подчёркивание. Без @ и других спецсимволов.", "username_hint": "Только буквы, цифры и подчёркивание. Без @ и других спецсимволов.",
"username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются." "username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются.",
"recover_title": "Восстановление доступа администратора",
"recover_help": "Аварийное восстановление: создаёт новый аккаунт администратора через установочный токен. Используйте, когда все администраторы потеряли доступ.",
"recover_token_hint": "Установочный токен из конфигурации сервера (admin.setup_token).",
"recover_button": "Создать администратора",
"recover_success": "Администратор создан. Теперь можно войти.",
"recover_failed": "Не удалось восстановить доступ",
"recover_link": "Потеряли доступ администратора? Восстановить здесь"
}, },
"worlds": { "worlds": {
"title": "Ваши миры", "title": "Ваши миры",
@@ -232,6 +239,8 @@
"logs_filter_apply": "Применить фильтры", "logs_filter_apply": "Применить фильтры",
"logs_stage": "Стадия", "logs_stage": "Стадия",
"logs_status": "Статус", "logs_status": "Статус",
"logs_world": "Мир",
"logs_filtered": "фильтр",
"logs_latency": "Задержка", "logs_latency": "Задержка",
"logs_tokens": "Токены", "logs_tokens": "Токены",
"logs_created": "Создано", "logs_created": "Создано",
@@ -282,7 +291,17 @@
"tool_calls_detected": "Обнаружены вызовы инструментов", "tool_calls_detected": "Обнаружены вызовы инструментов",
"no_tool_calls_warning_title": "Вызовы инструментов не возвращены", "no_tool_calls_warning_title": "Вызовы инструментов не возвращены",
"no_tool_calls_warning": "Модель не вернула вызовы инструментов. Это может означать, что модель не поддерживает function calling или использует нестандартный формат.", "no_tool_calls_warning": "Модель не вернула вызовы инструментов. Это может означать, что модель не поддерживает function calling или использует нестандартный формат.",
"raw_response": "Полный ответ модели" "raw_response": "Полный ответ модели",
"fetch_models": "Получить модели",
"fetched_models": "Доступные модели",
"pick_model": "Выберите модель…",
"fetch_models_failed": "Не удалось получить список моделей. Введите имя модели вручную.",
"text_replacements_title": "Замены текста",
"text_replacements_help": "Эти замены применяются ко всему выводу LLM-сцены. Пустое 'На' полностью удаляет слово/фразу.",
"text_replacements_empty": "Правил замены пока нет. Нажмите «Добавить правило», чтобы создать.",
"text_replacements_from": "С",
"text_replacements_to": "На",
"text_replacements_add": "Добавить правило"
}, },
"errors": { "errors": {
"generic": "Что-то пошло не так.", "generic": "Что-то пошло не так.",

View File

@@ -353,7 +353,7 @@ export const SessionsApi = {
* lives in the sessions API surface for grouping.) * lives in the sessions API surface for grouping.)
*/ */
generateIntro: (worldId: string) => generateIntro: (worldId: string) =>
request<GenerateIntroResponse>(`/worlds/${worldId}/generate-intro`, { method: "POST" }), request<GenerateIntroResponse>(`/sessions/worlds/${worldId}/generate-intro`, { method: "POST" }),
// SSE stream URLs (used by SSE client) // SSE stream URLs (used by SSE client)
iterateStreamUrl: (worldId: string, stepId: string) => iterateStreamUrl: (worldId: string, stepId: string) =>
@@ -387,7 +387,58 @@ export type LlmLogsQuery = {
per_page?: number; per_page?: number;
}; };
/** Body for POST /api/admin/recover (NO AUTH). */
export interface AdminRecoverPayload {
token: string;
email: string;
username: string;
password: string;
}
/** Response from POST /api/admin/recover. */
export interface AdminRecoverResponse {
ok: boolean;
id: string;
email: string;
username: string;
is_admin: boolean;
}
/** Response from POST /api/admin/llm/models. */
export interface LlmModelsResponse {
ok: boolean;
models?: string[];
count?: number;
error?: { code: string; message: string };
}
export const AdminApi = { export const AdminApi = {
/**
* Disaster-recovery admin creation. NO AUTH required — uses a setup token.
* Use when all admins have lost access. The route is mounted publicly by
* the backend.
*/
recover: (body: AdminRecoverPayload) =>
request<AdminRecoverResponse>("/admin/recover", { method: "POST", body }),
/**
* Fetches the list of available models from the configured LLM provider.
* Pass the api_url / api_key currently entered in the settings form so the
* backend can probe the provider directly (it does NOT read saved settings
* for this call — the user may have typed but not yet saved).
*/
listLlmModels: (apiUrl?: string, apiKey?: string) =>
request<LlmModelsResponse>(
"/admin/llm/models",
{
method: "POST",
query: {
api_url: apiUrl || undefined,
api_key: apiKey || undefined,
},
},
),
settings: () => request<AdminSettingsResponse>("/admin/settings"), settings: () => request<AdminSettingsResponse>("/admin/settings"),
updateSettings: (settings: Record<string, string>) => updateSettings: (settings: Record<string, string>) =>
request<{ updated: Record<string, string> }>("/admin/settings", { method: "PATCH", body: { settings } }), request<{ updated: Record<string, string> }>("/admin/settings", { method: "PATCH", body: { settings } }),

View File

@@ -0,0 +1,143 @@
import { useState, type FormEvent } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AdminApi, ApiError, toErrorMessage } from "@/lib/api";
import { useToastStore } from "@/stores/toastStore";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card } from "@/components/ui/Card";
/**
* Disaster-recovery admin creation page. Calls POST /api/admin/recover
* with a setup token (the same kind of token used by /register/admin).
* NO AUTH required — this route is public on the backend so it can be
* used when all admins have lost access.
*
* On success → redirect to /login with a success toast.
* On error → show the backend error message inline (e.g.
* "invalid_admin_token", "email_already_exists").
*/
export function AdminRecoverPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const pushToast = useToastStore((s) => s.push);
const [token, setToken] = useState("");
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [topError, setTopError] = useState<string | null>(null);
const validate = (): boolean => {
const next: Record<string, string> = {};
if (!token.trim()) next.token = t("errors.validation");
if (!email.includes("@")) next.email = t("errors.validation");
if (username.trim().length < 3) next.username = t("errors.validation");
if (password.length < 8) next.password = t("errors.validation");
if (password !== passwordConfirm) next.password_confirm = t("errors.validation");
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setTopError(null);
if (!validate()) return;
setSubmitting(true);
try {
await AdminApi.recover({
token: token.trim(),
email: email.trim(),
username: username.trim(),
password,
});
pushToast("success", t("auth.recover_success"));
navigate("/login");
} catch (err) {
// Backend returns error codes like "invalid_admin_token",
// "email_already_exists", etc. Surface them inline + as a toast.
let message: string;
if (err instanceof ApiError) {
message = err.message;
} else {
message = toErrorMessage(err, t("auth.recover_failed"));
}
setTopError(message);
pushToast("error", message);
} finally {
setSubmitting(false);
}
};
return (
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-md items-center p-4">
<Card className="w-full" title={t("auth.recover_title")}>
<p className="mb-3 text-xs text-fg-muted">
{t("auth.recover_help")}
</p>
<form onSubmit={handleSubmit} className="space-y-3">
<Input
label={t("auth.admin_token")}
value={token}
onChange={(e) => setToken(e.target.value)}
required
error={errors.token}
hint={t("auth.recover_token_hint")}
/>
<Input
label={t("auth.email")}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
error={errors.email}
/>
<Input
label={t("auth.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
error={errors.username}
hint={t("auth.username_hint")}
/>
<Input
label={t("auth.password")}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
error={errors.password}
/>
<Input
label={t("auth.password_confirm")}
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
autoComplete="new-password"
required
error={errors.password_confirm}
/>
{topError && (
<p className="rounded-md border border-err/30 bg-err/10 p-2 text-sm text-err">
{topError}
</p>
)}
<Button type="submit" loading={submitting} fullWidth>
{t("auth.recover_button")}
</Button>
<p className="text-center text-sm text-fg-muted">
<Link to="/login" className="text-accent hover:underline">
{t("auth.have_account")}
</Link>
</p>
</form>
</Card>
</div>
);
}

View File

@@ -62,6 +62,11 @@ export function LoginPage() {
{t("auth.no_account")} {t("auth.no_account")}
</Link> </Link>
</p> </p>
<p className="text-center text-xs text-fg-dim">
<Link to="/recover" className="text-fg-muted hover:text-accent hover:underline">
{t("auth.recover_link")}
</Link>
</p>
</form> </form>
</Card> </Card>
</div> </div>

View File

@@ -206,7 +206,13 @@ export function PlayPage() {
<div> <div>
<h2 className="text-base font-semibold text-fg">{world.name}</h2> <h2 className="text-base font-semibold text-fg">{world.name}</h2>
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
{world.current_time ? `${t("worlds.current_time")}: ${world.current_time}` : ""} {(() => {
// Prefer the human-readable form; fall back to the raw
// current_time string when the backend doesn't supply it
// (e.g. older session state cached locally).
const time = world.current_time_human || world.current_time;
return time ? `${t("worlds.current_time")}: ${time}` : "";
})()}
</p> </p>
</div> </div>
<SseStatus status={sseStatus} /> <SseStatus status={sseStatus} />

View File

@@ -66,13 +66,27 @@ export function WorldEditPage() {
} }
const isDraft = world.status === "draft"; const isDraft = world.status === "draft";
// Show the IntroSceneGenerator whenever the world is still a draft (the
// intro scene is the thing that flips a draft → ready) OR whenever the
// intro scene is missing for any reason (e.g. legacy data, interrupted
// build). After successful intro generation, `refreshWorld` updates the
// world to status "ready" with intro_scene set, and IntroSceneGenerator
// then returns null on its next render.
const showIntroGenerator = world.status === "draft" || !world.intro_scene;
// Prefer the human-readable time string; fall back to the raw value.
const displayedTime = world.current_time_human || world.current_time;
return ( return (
<div className="mx-auto max-w-7xl space-y-4 p-4"> <div className="mx-auto max-w-7xl space-y-4 p-4">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1> <h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1>
<p className="text-sm text-fg-muted">{world.name}</p> <p className="text-sm text-fg-muted">
{world.name}
{displayedTime && (
<span className="ml-2 text-fg-dim">· {t("worlds.current_time")}: {displayedTime}</span>
)}
</p>
</div> </div>
<Button <Button
variant="secondary" variant="secondary"
@@ -85,7 +99,7 @@ export function WorldEditPage() {
</Button> </Button>
</header> </header>
{isDraft && ( {showIntroGenerator && (
<IntroSceneGenerator world={world} onWorldUpdated={() => void refreshWorld()} /> <IntroSceneGenerator world={world} onWorldUpdated={() => void refreshWorld()} />
)} )}

View File

@@ -53,6 +53,9 @@ export interface WorldListItem {
status: WorldStatus; status: WorldStatus;
last_played_at: string | null; last_played_at: string | null;
current_time: string | null; current_time: string | null;
/** Human-readable form of `current_time` (e.g. "Day 1, 08:00"). May be
* absent on older backend versions — fall back to `current_time`. */
current_time_human?: string | null;
created_at: string; created_at: string;
preview_player_name: string | null; preview_player_name: string | null;
} }
@@ -125,6 +128,10 @@ export interface World {
environment: Environment; environment: Environment;
plot_rails: PlotRail[]; plot_rails: PlotRail[];
current_time: string | null; current_time: string | null;
/** Human-readable form of `current_time` (e.g. "Day 1, 08:00").
* Returned by GET /api/sessions/worlds/{id}/state on the world object.
* May be absent on older backend versions — fall back to `current_time`. */
current_time_human?: string | null;
status: WorldStatus; status: WorldStatus;
intro_scene: string | null; intro_scene: string | null;
created_at: string; created_at: string;

View File

@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/IntroSceneGenerator.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/IntroSceneGenerator.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRecoverPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"}