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/),
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
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"])
# --------------------------------------------------------------------------- #
# 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
# --------------------------------------------------------------------------- #
@@ -95,8 +165,13 @@ async def list_llm_logs(
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)
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 {
"items": [LlmLogOut.model_validate(r).model_dump(mode="json") for r in rows],
"items": items,
"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
# --------------------------------------------------------------------------- #

View File

@@ -76,10 +76,14 @@ async def get_state(
next_actions = recent_steps[-1]["suggested_actions"] if recent_steps else []
if world.intro_scene and not recent_steps:
next_actions = []
from app.core.time_utils import format_time_human
return {
"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,
"status": world.status,
},
"environment": world.environment,
"recent_steps": recent_steps,

View File

@@ -69,11 +69,13 @@ async def list_worlds(
env = w.environment or {}
player = env.get("player") if isinstance(env, dict) else None
pname = player.get("name") if isinstance(player, dict) else None
from app.core.time_utils import format_time_human
items.append({
"id": str(w.id), "name": w.name, "description": w.description,
"language": w.language, "status": w.status,
"last_played_at": w.last_played_at.isoformat() if w.last_played_at else None,
"current_time": w.current_time,
"current_time_human": format_time_human(w.current_time, w.language),
"created_at": w.created_at.isoformat(),
"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.og_image_url": {"value": "", "description": "OpenGraph image URL"},
"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.
@@ -185,3 +189,24 @@ async def get_admin_setup_token(session: AsyncSession) -> str:
token = secrets.token_urlsafe(16)
await set_setting(session, "admin.setup_token", 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()
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:
"""Render a compact human-readable summary of entity schemas for LLM prompts."""
lines: list[str] = []

View File

@@ -112,6 +112,9 @@ async def run_iteration(
if result.ok:
scene_text = result.data.get("scene_text", "")
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_delta_time = delta_time
await sse.emit("scene_complete", {

View File

@@ -23,7 +23,7 @@ _logger = get_logger(__name__)
class EntityCreateTool(Tool):
name = "entity_create"
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 = (
"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."
@@ -800,7 +800,7 @@ class RagAddTool(Tool):
class SubmitPlanTool(Tool):
name = "submit_plan"
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."
parameters_schema = {
"type": "object",
@@ -884,7 +884,7 @@ class SuggestActionsTool(Tool):
class AskUserTool(Tool):
name = "ask_user"
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."
parameters_schema = {
"type": "object",
@@ -947,7 +947,7 @@ class ProposeChangesTool(Tool):
class CommentToUserTool(Tool):
name = "comment_to_user"
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)."
parameters_schema = {
"type": "object",

View File

@@ -17,7 +17,7 @@ def _find_schema(world_schemas: list[dict], type_name: str) -> dict | None:
class SchemaAddTypeTool(Tool):
name = "schema_add_type"
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."
parameters_schema = {
"type": "object",
@@ -52,7 +52,7 @@ class SchemaAddTypeTool(Tool):
class SchemaAddFieldTool(Tool):
name = "schema_add_field"
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."
parameters_schema = {
"type": "object",
@@ -86,7 +86,7 @@ class SchemaAddFieldTool(Tool):
class SchemaRemoveFieldTool(Tool):
name = "schema_remove_field"
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."
parameters_schema = {
"type": "object",
@@ -115,7 +115,7 @@ class SchemaRemoveFieldTool(Tool):
class SchemaModifyFieldTool(Tool):
name = "schema_modify_field"
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."
parameters_schema = {
"type": "object",

View File

@@ -2,11 +2,15 @@
Flow (see §9.1 of TDD):
1. Receive template (preset or form).
2. Generate schemas + environment_schema + rules + time_schema.
3. Generate initial environment (player + current_location + plot_rails).
4. Generate initial entities (locations, NPCs, items).
2. Generate schemas + environment_schema + rules + time_schema (via tools or preset).
3. Generate initial environment (player + current_location + plot_rails) via tools.
4. Generate initial entities (locations, NPCs, items) via entity_create tool.
5. Generate intro scene + suggested actions.
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
@@ -15,12 +19,13 @@ import json
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient, MockLlmClient
from app.core.logging import get_logger
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.tools.base import ToolContext, get_registry
from app.models import World, WorldPreset
@@ -41,93 +46,80 @@ async def run_world_builder(
) -> None:
"""Run the full world_builder flow for a draft world.
Emits SSE events and updates the world row in place. On error, emits `error`
and returns (the world stays in status='draft').
Each stage is resumable: if the world already has the data from a previous
run (e.g. schemas exist), that stage is skipped.
"""
try:
# ---- Step 1: Generate schemas / rules / time_schema / environment_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:
world.schemas = preset.schemas
world.environment_schema = preset.environment_schema
world.rules = preset.rules
world.time_schema = preset.time_schema
world.environment = dict(preset.environment_initial)
# ============ 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..."})
if preset and preset.schemas:
# Use preset schemas directly
world.schemas = preset.schemas
world.environment_schema = preset.environment_schema
world.rules = preset.rules
world.time_schema = preset.time_schema
await db.commit()
else:
# Generate via LLM using schema_add_type tool
ok = await _generate_schemas_via_tools(
db=db, world=world, llm=llm, sse=sse,
player_name=player_name, notes=notes,
)
if not ok:
await sse.error("schema_generation_failed", "Failed to generate schemas")
return
await sse.emit("world_schema_generated", {
"schemas": world.schemas, "environment_schema": world.environment_schema,
})
else:
resp = await llm.complete(
stage="world_builder_schema",
messages=[{"role": "system", "content": schema_prompt}],
temperature=0.5,
max_tokens=4096,
world_id=world.id,
session=db,
)
try:
content = resp["message"].get("content", "")
# 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
world.schemas = schema_data.get("schemas", [])
world.environment_schema = schema_data.get("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"})
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
env = dict(world.environment or {})
if isinstance(env.get("player"), dict):
env["player"]["name"] = player_name
else:
env["player"] = {"name": player_name}
# If no player, create a minimal one
env["player"] = {"name": player_name, "stats": {"health": 100}}
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)
if not preset or not preset.environment_initial:
# Check if environment has current_location and plot_rails
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..."})
env_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,
)
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()
if preset and preset.environment_initial and not env.get("current_location"):
# Use preset environment but ensure player name
preset_env = dict(preset.environment_initial)
if isinstance(preset_env.get("player"), dict):
preset_env["player"]["name"] = player_name
world.environment = preset_env
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})
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({
"name": world.name, "language": world.language,
"schemas": world.schemas, "environment_schema": world.environment_schema,
@@ -135,76 +127,93 @@ async def run_world_builder(
"current_time": world.current_time,
})
if not ok:
await sse.error("world_invalid", "World validation failed", details=errors)
return
# ---- Step 3: Generate entities via tool-calling loop
await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."})
await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="world_builder_entities",
system_prompt=get_prompt("world_builder_entities", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
schemas_summary=summarize_schemas(world.schemas or []),
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
max_substeps=12,
),
terminal_tool="submit_plan",
max_substeps=12,
settings={}, # world_builder uses fixed defaults
)
await db.commit()
await sse.emit("entities_generated", {"world_id": str(world.id)})
# ---- Step 4: Generate intro scene
await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
from sqlalchemy import select
# Don't fail — log and continue, the world may still be usable
_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]),
})
# ============ Stage 3: Entities ============
# Check if world already has entities
from app.models import Entity
entities = (
existing_entities = (
await db.execute(
select(Entity).where(
Entity.world_id == world.id, Entity.deleted_at.is_(None)
)
).limit(1)
)
).scalars().all()
entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities[:20]
)
intro_prompt = get_prompt("intro_scene", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
current_time=world.current_time,
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
plot_rails_json=json.dumps(world.plot_rails, ensure_ascii=False, indent=2),
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",
max_substeps=3,
settings={},
)
scene_text = ""
delta_time = "hours_1"
if scene_result and scene_result.get("ok"):
scene_text = scene_result.get("data", {}).get("scene_text", "")
delta_time = scene_result.get("data", {}).get("delta_time", "hours_1")
world.intro_scene = scene_text
from app.core.time_utils import advance_time
).scalars().first()
if not existing_entities:
await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."})
await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="world_builder_entities",
system_prompt=get_prompt("world_builder_entities", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
schemas_summary=summarize_schemas(world.schemas or []),
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
max_substeps=15,
),
terminal_tool="submit_plan",
max_substeps=15,
settings={},
)
await db.commit()
await sse.emit("entities_generated", {"world_id": str(world.id)})
else:
await sse.emit("step", {"step": "skipping_entities", "message": "Entities already exist, skipping..."})
world.current_time = advance_time(world.current_time, delta_time, world.time_schema)
await db.commit()
await sse.emit("intro_scene_complete", {
"text": scene_text, "delta_time": delta_time, "current_time": world.current_time,
})
# ============ Stage 4: Intro scene ============
if not world.intro_scene:
await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
entities = (
await db.execute(
select(Entity).where(
Entity.world_id == world.id, Entity.deleted_at.is_(None)
)
)
).scalars().all()
entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities[:20]
) or "(no entities)"
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_description=world.description or "",
language=world.language,
current_time=world.current_time,
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
plot_rails_json=json.dumps(world.plot_rails or {}, ensure_ascii=False, indent=2),
entities_summary=entities_summary,
),
terminal_tool="submit_step",
max_substeps=3,
settings={},
)
scene_text = ""
delta_time = "hours_1"
if scene_result and scene_result.get("ok"):
scene_text = scene_result.get("data", {}).get("scene_text", "")
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.current_time = advance_time(world.current_time, delta_time, world.time_schema)
await db.commit()
await sse.emit("intro_scene_complete", {
"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
world.status = "ready"
@@ -215,11 +224,76 @@ async def run_world_builder(
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:
"""Remove ```json ... ``` fences if present."""
s = text.strip()
if s.startswith("```"):
# Remove first line (``` or ```json)
s = s.split("\n", 1)[1] if "\n" in s else s
if s.endswith("```"):
s = s[:-3]
@@ -246,7 +320,7 @@ async def _run_tool_loop(
)
messages: list[dict[str, Any]] = [
{"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)
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": "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
messages.append(msg)
for tc in tool_calls:
fn = tc.get("function", {})
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
tname = fn.get("name", "")
try:
targs = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
targs = {}
result = await registry.execute(tname, targs, ctx)
# Tool result as a tool message
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),

View File

@@ -121,7 +121,6 @@ async def run_world_editor(
for tc in tcs:
fn = tc.get("function") or {}
if not isinstance(fn, dict):
# Some models put the function name/args directly on tc
fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")}
tname = fn.get("name", "")
args_str = fn.get("arguments", "{}")
@@ -136,7 +135,6 @@ async def run_world_editor(
"question": targs.get("question", ""),
"options": targs.get("options"),
})
# Wait for user answer via REST
fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
_pending_clarifications[world.id] = fut
try:
@@ -161,7 +159,6 @@ async def run_world_editor(
"diff": diff,
"comment": comment,
})
# Wait for user accept/reject via REST
fut2: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
_pending_changes[world.id] = fut2
try:
@@ -176,21 +173,12 @@ async def run_world_editor(
await _apply_diff(world, diff)
await db.commit()
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:
await sse.emit("discard_changes", {})
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": tname,
"content": json.dumps({"ok": True, "applied": False, "reason": "user_rejected"}),
})
continue
# After propose_changes (accepted or rejected), END the loop.
# Don't make another LLM call — the user's decision is final.
done = True
break
# Regular tool — execute
result = await registry.execute(tname, targs, ctx)
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 = {
"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
already been generated.
Your task: set up the initial environment for the world by calling the `env_update` tool.
The environment must include:
- "player": a character object matching the `character` schema. 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).
- "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
thematic starting place like "Tavern" or "Camp").
- "plot_rails": {{"hooks": [<2 short story hooks>], "current_goals": [<1 starting goal>],
"completed_goals": []}}
- Any other fields declared in environment_schema.
- `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).
- `current_location`: a string naming the starting location (e.g. "The Rusty Tankard Tavern" or "Station Command Module").
- `plot_rails`: an object with `hooks` (array of 2 short story hooks), `current_goals` (array of 1 starting goal), and `completed_goals` (empty array).
# How to use env_update
Call env_update with a `patch` argument. The patch is a dict of field_path -> value.
To set nested fields, use dotted paths.
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 name: {world_name}
@@ -30,7 +40,12 @@ Schemas:
Environment schema:
{environment_schema_json}
# Output
Return ONLY a JSON object. No commentary. The output must conform to environment_schema.
# After setting up the environment
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 = {
"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.
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)
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.
# Player request
Mode: {mode}
@@ -37,12 +13,40 @@ Player name: {player_name}
Language: {language}
Notes: {notes}
# Rules for output
- Return ONLY a JSON object. No commentary.
- Keep schemas small (3-6 fields per type).
- "stats.health" must be integer with min=0 max=100.
- Always include `player` (character) and `current_location` (string) in environment_schema.
- The world is for a 7B-parameter LLM — keep schemas readable.
# Required entity types
You MUST create at minimum these entity types (call schema_add_type for each):
1. `character` — with fields: name (string, required), description (string), stats (object, required, with sub-fields: health integer 0-100 required, mana integer 0-100, strength integer 1-20), inventory (array), relationship (string)
2. `item` — with fields: name (string, required), description (string), qty (integer 1-9999), value (integer)
3. `location` — with fields: name (string, required), description (string, required), exits (array of strings), is_safe (boolean)
4. `faction` — with fields: name (string, required), description (string), alignment (string)
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 { RegisterPage } from "@/pages/RegisterPage";
import { AdminRegisterPage } from "@/pages/AdminRegisterPage";
import { AdminRecoverPage } from "@/pages/AdminRecoverPage";
import { WorldsListPage } from "@/pages/WorldsListPage";
import { WorldBuilderPage } from "@/pages/WorldBuilderPage";
import { WorldEditPage } from "@/pages/WorldEditPage";
@@ -109,6 +110,16 @@ export default function App() {
</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 */}
<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 (
<div className="space-y-4">
<Card title={t("admin.tab_logs")}>
@@ -149,6 +154,14 @@ export function LlmLogsTable() {
<thead>
<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_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_latency")}</th>
<th className="p-2">{t("admin.logs_tokens")}</th>
@@ -160,6 +173,9 @@ export function LlmLogsTable() {
{data.items.map((log) => (
<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 text-fg-muted">
{log.world_id ? log.world_id.slice(0, 8) : "—"}
</td>
<td className="p-2">
<span className={`badge ${statusColor(log.status)}`}>
{log.status}
@@ -230,6 +246,7 @@ export function LlmLogsTable() {
badgeClass={statusColor(detail.status)}
/>
<Field label={t("admin.model")} value={detail.model || "—"} />
<Field label={t("admin.logs_world")} value={detail.world_id || "—"} />
<Field
label={t("admin.logs_latency")}
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 { AdminApi } from "@/lib/api";
import { AdminApi, ApiError } from "@/lib/api";
import { useToastStore } from "@/stores/toastStore";
import { refreshUiSettings } from "@/stores/uiSettingsStore";
import type { AdminSettingsResponse } from "@/types";
@@ -33,6 +33,11 @@ function groupFor(key: string): GroupDef | 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. */
type FieldType = "integer" | "float" | "boolean" | "provider" | "secret" | "text";
@@ -143,6 +148,9 @@ export function SettingsPanel() {
for (const key of Object.keys(data.settings)) {
const g = groupFor(key);
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] });
}
// 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) {
return (
<div className="flex items-center justify-center p-8">
@@ -236,18 +268,30 @@ export function SettingsPanel() {
</div>
{GROUPS.map((g) => {
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 (
<SettingsGroupCard
key={g.id}
title={t(g.labelKey)}
entries={entries}
draft={draft}
onChange={(key, value) =>
setDraft((d) => ({ ...d, [key]: value }))
}
onSave={() => void handleSaveGroup(g.id)}
/>
<Fragment key={g.id}>
<SettingsGroupCard
groupId={g.id}
title={t(g.labelKey)}
entries={entries || []}
draft={draft}
onChange={(key, value) =>
setDraft((d) => ({ ...d, [key]: value }))
}
onSave={() => void handleSaveGroup(g.id)}
/>
{g.id === "llm" && (
<TextReplacementsCard
rawValue={draft["llm.text_replacements"] ?? ""}
onSave={(v) => void handleSaveKey("llm.text_replacements", v)}
/>
)}
</Fragment>
);
})}
</div>
@@ -255,6 +299,7 @@ export function SettingsPanel() {
}
interface SettingsGroupCardProps {
groupId: string;
title: string;
entries: Array<{ key: string; description?: string }>;
draft: Record<string, string>;
@@ -262,7 +307,7 @@ interface SettingsGroupCardProps {
onSave: () => void;
}
function SettingsGroupCard({ title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const handleSave = async () => {
@@ -273,6 +318,17 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
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 (
<Card
title={title}
@@ -282,16 +338,29 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
</Button>
}
>
<div className="grid gap-3 sm:grid-cols-2">
{entries.map(({ key, description }) => (
<SettingField
key={key}
settingKey={key}
description={description}
value={draft[key] ?? ""}
onChange={(v) => onChange(key, v)}
<div className="space-y-3">
{showGrid && (
<div className="grid gap-3 sm:grid-cols-2">
{regularEntries.map(({ key, description }) => (
<SettingField
key={key}
settingKey={key}
description={description}
value={draft[key] ?? ""}
onChange={(v) => onChange(key, v)}
/>
))}
</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>
);
@@ -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: Array<{ phase: string; name?: string; done?: boolean }>;
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;
message?: string;
className?: string;

View File

@@ -201,8 +201,11 @@ export function IntroSceneGenerator({ world, onWorldUpdated }: IntroSceneGenerat
void start();
};
// Hide the generator once the world is ready.
if (world.status === "ready") return null;
// Hide the generator once the world is ready AND has an intro scene.
// 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";

View File

@@ -21,15 +21,23 @@ import { SseStatus } from "@/components/sessions/SseStatus";
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 {
phase: "form" | "building" | "done" | "error";
currentPhase?: string;
phases: Array<{ phase: string; name?: string; done: boolean }>;
step?: number;
step?: number | string;
totalSteps?: number;
message?: string;
introScene: string;
logs: string[];
logs: BuilderLogEntry[];
sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
}
@@ -131,7 +139,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
...s,
phase: "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"));
controllerRef.current?.close();
@@ -139,16 +147,29 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
}
case "warning": {
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;
}
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) => ({
...s,
step: d.step,
step: stepVal,
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;
}
@@ -181,13 +202,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
break;
}
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;
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;
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;
case "intro_scene_chunk": {
const d = event.data as { text: string };
@@ -464,9 +485,24 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
{state.logs.length > 0 && (
<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>
<pre className="mt-2 max-h-48 overflow-auto text-[10px] text-fg-dim">
{state.logs.join("\n")}
</pre>
<div className="mt-2 max-h-48 overflow-auto font-mono text-[10px] leading-relaxed">
{state.logs.map((entry, i) => (
<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>
)}
{state.phase === "done" && (

View File

@@ -45,6 +45,9 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
const isArchived = world.status === "archived";
const isDraft = world.status === "draft";
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 () => {
setRestoring(true);
@@ -103,7 +106,7 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
</div>
<div>
<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 className="col-span-2">
<dt className="text-fg-dim">{t("worlds.last_played")}</dt>

View File

@@ -66,7 +66,14 @@
"register_failed": "Registration failed",
"session_expired": "Session expired, please sign in again.",
"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": {
"title": "Your Worlds",
@@ -232,6 +239,8 @@
"logs_filter_apply": "Apply filters",
"logs_stage": "Stage",
"logs_status": "Status",
"logs_world": "World",
"logs_filtered": "filtered",
"logs_latency": "Latency",
"logs_tokens": "Tokens",
"logs_created": "Created",
@@ -282,7 +291,17 @@
"tool_calls_detected": "Tool calls detected",
"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.",
"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": {
"generic": "Something went wrong.",

View File

@@ -66,7 +66,14 @@
"register_failed": "Не удалось зарегистрироваться",
"session_expired": "Сессия истекла, пожалуйста, войдите снова.",
"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": {
"title": "Ваши миры",
@@ -232,6 +239,8 @@
"logs_filter_apply": "Применить фильтры",
"logs_stage": "Стадия",
"logs_status": "Статус",
"logs_world": "Мир",
"logs_filtered": "фильтр",
"logs_latency": "Задержка",
"logs_tokens": "Токены",
"logs_created": "Создано",
@@ -282,7 +291,17 @@
"tool_calls_detected": "Обнаружены вызовы инструментов",
"no_tool_calls_warning_title": "Вызовы инструментов не возвращены",
"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": {
"generic": "Что-то пошло не так.",

View File

@@ -353,7 +353,7 @@ export const SessionsApi = {
* lives in the sessions API surface for grouping.)
*/
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)
iterateStreamUrl: (worldId: string, stepId: string) =>
@@ -387,7 +387,58 @@ export type LlmLogsQuery = {
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 = {
/**
* 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"),
updateSettings: (settings: Record<string, string>) =>
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")}
</Link>
</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>
</Card>
</div>

View File

@@ -206,7 +206,13 @@ export function PlayPage() {
<div>
<h2 className="text-base font-semibold text-fg">{world.name}</h2>
<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>
</div>
<SseStatus status={sseStatus} />

View File

@@ -66,13 +66,27 @@ export function WorldEditPage() {
}
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 (
<div className="mx-auto max-w-7xl space-y-4 p-4">
<header className="flex items-center justify-between">
<div>
<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>
<Button
variant="secondary"
@@ -85,7 +99,7 @@ export function WorldEditPage() {
</Button>
</header>
{isDraft && (
{showIntroGenerator && (
<IntroSceneGenerator world={world} onWorldUpdated={() => void refreshWorld()} />
)}

View File

@@ -53,6 +53,9 @@ export interface WorldListItem {
status: WorldStatus;
last_played_at: 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;
preview_player_name: string | null;
}
@@ -125,6 +128,10 @@ export interface World {
environment: Environment;
plot_rails: PlotRail[];
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;
intro_scene: string | null;
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"}