fix
This commit is contained in:
60
CHANGELOG.md
60
CHANGELOG.md
@@ -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.).
|
||||
|
||||
126
app/api/admin.py
126
app/api/admin.py
@@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
""",
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` : "—"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Что-то пошло не так.",
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
143
frontend/src/pages/AdminRecoverPage.tsx
Normal file
143
frontend/src/pages/AdminRecoverPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user