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/),
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.3.0] — 2026-06-21
|
||||||
|
|
||||||
|
Major release: world builder rewritten to use tools (instead of JSON), resumable builder flow, admin recovery, LLM model list, text replacements, human-readable time.
|
||||||
|
|
||||||
|
### Backend — Critical: World Builder rewritten to use tools
|
||||||
|
|
||||||
|
- **`world_builder_schema` stage**: previously asked the LLM to output a JSON object with schemas, which frequently failed validation. Now the LLM calls `schema_add_type` tool for each entity type. Added `world_builder_schema` and `world_builder_env` to the `stages` set of all relevant tools (schema_add_type, env_update, entity_create, submit_plan, etc.).
|
||||||
|
- **`world_builder_env` stage**: previously asked for JSON. Now the LLM calls `env_update` tool to set player, current_location, and plot_rails.
|
||||||
|
- **Resumable builder**: each stage checks if the world already has the needed data and skips if so. If schemas exist → skip schema generation. If environment has current_location → skip env generation. If entities exist → skip entity generation. If intro_scene exists → skip intro generation. This allows re-running the builder after a failure at any stage without redoing earlier stages.
|
||||||
|
- **Validation warnings instead of failures**: if `validate_world` finds issues after env generation, the builder emits a `warning` SSE event but continues (instead of failing). The world may still be usable.
|
||||||
|
- **Better tool-loop prompt**: the user message now says "Use the available tools to accomplish the task. When done, call {terminal_tool}." to encourage tool use.
|
||||||
|
- **Updated prompts**: `world_builder_schema` and `world_builder_env` prompts now describe the tools to use and give examples of tool arguments.
|
||||||
|
|
||||||
|
### Backend — Critical: World Editor fixes
|
||||||
|
|
||||||
|
- **No extra LLM call after propose_changes**: after the user accepts or rejects proposed changes, the editor loop now breaks immediately. Previously it made another LLM call (which returned empty text), wasting API requests.
|
||||||
|
- **`'str' object has no attribute 'get'` fix**: already in v1.2.0, but now also handles cases where `function` is not a dict.
|
||||||
|
|
||||||
|
### Backend — New: Admin recovery
|
||||||
|
|
||||||
|
- **`POST /api/admin/recover`** (NO AUTH required) — creates a new admin user using the `admin.setup_token` (printed on every backend startup). Body: `{token, email, username, password}`. For disaster recovery when all existing admins lost access. Returns 403 `invalid_admin_token` if the token doesn't match.
|
||||||
|
|
||||||
|
### Backend — New: LLM model list
|
||||||
|
|
||||||
|
- **`POST /api/admin/llm/models`** (admin) — fetches the list of available models from an OpenAI-compatible API (`GET {api_url}/models`). Returns `{ok: true, models: [...], count: N}` or `{ok: false, error: {...}, models: []}`. Uses the same `_resolve` helper as test endpoints (ignores masked api_key values).
|
||||||
|
|
||||||
|
### Backend — New: Text replacements
|
||||||
|
|
||||||
|
- **New setting `llm.text_replacements`**: a JSON array of `{from: string, to: string}` pairs. Applied to all LLM scene_text output (both orchestrator Phase 2 and intro_scene). Use empty `to` to remove a word/phrase entirely.
|
||||||
|
- **`apply_text_replacements(session, text)`** helper in `settings_service.py`.
|
||||||
|
- Applied in `game_master.py` (Phase 2 writer) and `world_builder.py` (intro scene).
|
||||||
|
|
||||||
|
### Backend — New: Human-readable time
|
||||||
|
|
||||||
|
- **`format_time_human(time_str, language)`** in `time_utils.py` — converts `"day_1_hour_8"` → `"Day 1, 08:00"` (en) or `"День 1, 08:00"` (ru). Supports years, days, hours, minutes.
|
||||||
|
- **`GET /api/worlds`** now returns `current_time_human` alongside `current_time`.
|
||||||
|
- **`GET /api/sessions/worlds/{id}/state`** now returns `current_time_human` and `status` in the world object.
|
||||||
|
|
||||||
|
### Backend — New: world_id in LLM logs
|
||||||
|
|
||||||
|
- **`GET /api/admin/llm-logs`** now includes `world_id` (string UUID or null) on each log item. Useful for the admin UI to show which world a log belongs to, even when not filtering by world_id.
|
||||||
|
|
||||||
|
### Backend — Route fix
|
||||||
|
|
||||||
|
- **404 on generate-intro**: the frontend was calling `/api/worlds/{id}/generate-intro` but the route is at `/api/sessions/worlds/{id}/generate-intro`. Fixed the frontend API helper to use the correct path.
|
||||||
|
|
||||||
|
### Frontend — 14 files changed, 1 new
|
||||||
|
|
||||||
|
- **Admin recovery page**: new `/recover` route (public, no auth). Form with token/email/username/password. Link from LoginPage: "Lost admin access? Recover here".
|
||||||
|
- **LLM model list dropdown**: "Fetch models" button next to the model input in SettingsPanel. Fetches from `POST /api/admin/llm/models`. Shows a `<select>` dropdown on success, or a "Could not fetch" hint on failure. Cached in component state.
|
||||||
|
- **Text replacements UI**: new card in SettingsPanel with add/remove rows for `{from, to}` pairs. Saved to `llm.text_replacements` setting.
|
||||||
|
- **World column in LLM logs table**: shows first 8 chars of `world_id` (or "—"). Filter indicator in header. Full `world_id` in detail modal.
|
||||||
|
- **Human-readable time**: `current_time_human` used in WorldCard, PlayPage, WorldEditPage. Falls back to `current_time` if not available.
|
||||||
|
- **World builder skipped stages**: `skipping_*` step events shown in muted blue color.
|
||||||
|
- **IntroSceneGenerator visibility**: shown for draft worlds OR worlds with empty intro_scene (not just stuck worlds).
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Backend: 68 unit tests pass, 52 routes.
|
||||||
|
- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (378 KB JS / 23 KB CSS, ~114 KB gzipped).
|
||||||
|
|
||||||
## [1.2.0] — 2026-06-21
|
## [1.2.0] — 2026-06-21
|
||||||
|
|
||||||
This release fixes critical bugs that prevented world creation, world editing, and LLM tool-calling with local models (gemma, qwen, etc.).
|
This release fixes critical bugs that prevented world creation, world editing, and LLM tool-calling with local models (gemma, qwen, etc.).
|
||||||
|
|||||||
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"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Admin recovery — create a new admin when all existing admins lost access.
|
||||||
|
# This endpoint is NOT behind require_admin (it's for recovery). It requires
|
||||||
|
# the admin.setup_token from settings, which is printed on every startup.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.post("/recover", response_model=dict)
|
||||||
|
async def recover_admin(
|
||||||
|
body: dict,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new admin user using the admin setup token.
|
||||||
|
|
||||||
|
This endpoint is for disaster recovery when all existing admins have lost
|
||||||
|
access. It requires the `admin.setup_token` (printed on backend startup)
|
||||||
|
and creates a new admin user.
|
||||||
|
|
||||||
|
Body: {token, email, username, password}
|
||||||
|
"""
|
||||||
|
from app.core.security import hash_password, validate_password_strength
|
||||||
|
from app.core.settings_service import get_admin_setup_token
|
||||||
|
from app.models import User as UserModel
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
token = body.get("token", "")
|
||||||
|
expected_token = await get_admin_setup_token(db)
|
||||||
|
if token != expected_token:
|
||||||
|
raise HTTPException(403, "invalid_admin_token")
|
||||||
|
|
||||||
|
email = body.get("email", "").strip()
|
||||||
|
username = body.get("username", "").strip()
|
||||||
|
password = body.get("password", "")
|
||||||
|
|
||||||
|
if not email or not username or not password:
|
||||||
|
raise HTTPException(400, "email, username, and password are required")
|
||||||
|
|
||||||
|
errors = validate_password_strength(password)
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(400, errors[0])
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserModel).where(
|
||||||
|
or_(UserModel.email == email, UserModel.username == username)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
if existing.email == email:
|
||||||
|
raise HTTPException(400, "email_already_exists")
|
||||||
|
raise HTTPException(400, "username_already_exists")
|
||||||
|
|
||||||
|
user = UserModel(
|
||||||
|
email=email,
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
is_admin=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": str(user.id),
|
||||||
|
"email": user.email,
|
||||||
|
"username": user.username,
|
||||||
|
"is_admin": user.is_admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Settings
|
# Settings
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -95,8 +165,13 @@ async def list_llm_logs(
|
|||||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||||
stmt = stmt.order_by(LlmCallLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
|
stmt = stmt.order_by(LlmCallLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page)
|
||||||
rows = (await db.execute(stmt)).scalars().all()
|
rows = (await db.execute(stmt)).scalars().all()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
d = LlmLogOut.model_validate(r).model_dump(mode="json")
|
||||||
|
d["world_id"] = str(r.world_id) if r.world_id else None
|
||||||
|
items.append(d)
|
||||||
return {
|
return {
|
||||||
"items": [LlmLogOut.model_validate(r).model_dump(mode="json") for r in rows],
|
"items": items,
|
||||||
"total": total, "page": page, "per_page": per_page,
|
"total": total, "page": page, "per_page": per_page,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +292,55 @@ async def stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# LLM model list — fetch available models from the LLM provider
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@router.post("/llm/models")
|
||||||
|
async def list_llm_models(
|
||||||
|
api_url: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""Fetch the list of available models from an OpenAI-compatible API.
|
||||||
|
|
||||||
|
Returns {ok: true, models: ["model1", "model2", ...]} on success.
|
||||||
|
Returns {ok: false, error: {...}} on failure (no auto-fetch available).
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
settings = await get_all_settings(db)
|
||||||
|
api_url = _resolve(api_url, settings.get("llm.api_url", ""))
|
||||||
|
api_key = _resolve(api_key, settings.get("llm.api_key", ""))
|
||||||
|
if not api_url:
|
||||||
|
return {"ok": False, "error": {"code": "not_configured",
|
||||||
|
"message": "llm.api_url is empty"},
|
||||||
|
"models": []}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{api_url.rstrip('/')}/models",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"} if api_key else {},
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return {"ok": False,
|
||||||
|
"error": {"code": "api_error",
|
||||||
|
"message": f"HTTP {resp.status_code}: {resp.text[:200]}"},
|
||||||
|
"models": []}
|
||||||
|
data = resp.json()
|
||||||
|
models = []
|
||||||
|
for m in data.get("data", []):
|
||||||
|
mid = m.get("id") or m.get("name")
|
||||||
|
if mid:
|
||||||
|
models.append(mid)
|
||||||
|
models.sort()
|
||||||
|
return {"ok": True, "models": models, "count": len(models)}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"ok": False,
|
||||||
|
"error": {"code": "connection_failed", "message": str(e)},
|
||||||
|
"models": []}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Helpers for test endpoints
|
# Helpers for test endpoints
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -76,10 +76,14 @@ async def get_state(
|
|||||||
next_actions = recent_steps[-1]["suggested_actions"] if recent_steps else []
|
next_actions = recent_steps[-1]["suggested_actions"] if recent_steps else []
|
||||||
if world.intro_scene and not recent_steps:
|
if world.intro_scene and not recent_steps:
|
||||||
next_actions = []
|
next_actions = []
|
||||||
|
from app.core.time_utils import format_time_human
|
||||||
return {
|
return {
|
||||||
"world": {
|
"world": {
|
||||||
"id": str(world.id), "name": world.name, "current_time": world.current_time,
|
"id": str(world.id), "name": world.name,
|
||||||
|
"current_time": world.current_time,
|
||||||
|
"current_time_human": format_time_human(world.current_time, world.language),
|
||||||
"language": world.language, "intro_scene": world.intro_scene,
|
"language": world.language, "intro_scene": world.intro_scene,
|
||||||
|
"status": world.status,
|
||||||
},
|
},
|
||||||
"environment": world.environment,
|
"environment": world.environment,
|
||||||
"recent_steps": recent_steps,
|
"recent_steps": recent_steps,
|
||||||
|
|||||||
@@ -69,11 +69,13 @@ async def list_worlds(
|
|||||||
env = w.environment or {}
|
env = w.environment or {}
|
||||||
player = env.get("player") if isinstance(env, dict) else None
|
player = env.get("player") if isinstance(env, dict) else None
|
||||||
pname = player.get("name") if isinstance(player, dict) else None
|
pname = player.get("name") if isinstance(player, dict) else None
|
||||||
|
from app.core.time_utils import format_time_human
|
||||||
items.append({
|
items.append({
|
||||||
"id": str(w.id), "name": w.name, "description": w.description,
|
"id": str(w.id), "name": w.name, "description": w.description,
|
||||||
"language": w.language, "status": w.status,
|
"language": w.language, "status": w.status,
|
||||||
"last_played_at": w.last_played_at.isoformat() if w.last_played_at else None,
|
"last_played_at": w.last_played_at.isoformat() if w.last_played_at else None,
|
||||||
"current_time": w.current_time,
|
"current_time": w.current_time,
|
||||||
|
"current_time_human": format_time_human(w.current_time, w.language),
|
||||||
"created_at": w.created_at.isoformat(),
|
"created_at": w.created_at.isoformat(),
|
||||||
"preview_player_name": pname,
|
"preview_player_name": pname,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
|
|||||||
"ui.logo_url": {"value": "/icon.png", "description": "Logo URL"},
|
"ui.logo_url": {"value": "/icon.png", "description": "Logo URL"},
|
||||||
"ui.og_image_url": {"value": "", "description": "OpenGraph image URL"},
|
"ui.og_image_url": {"value": "", "description": "OpenGraph image URL"},
|
||||||
"admin.setup_token": {"value": "", "description": "Admin setup token"},
|
"admin.setup_token": {"value": "", "description": "Admin setup token"},
|
||||||
|
"llm.text_replacements": {
|
||||||
|
"value": [],
|
||||||
|
"description": "List of {from, to} pairs. Each 'from' substring in LLM scene_text output is replaced with 'to' (can be empty string to remove).",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Keys whose values should never be returned to the client in plaintext.
|
# Keys whose values should never be returned to the client in plaintext.
|
||||||
@@ -185,3 +189,24 @@ async def get_admin_setup_token(session: AsyncSession) -> str:
|
|||||||
token = secrets.token_urlsafe(16)
|
token = secrets.token_urlsafe(16)
|
||||||
await set_setting(session, "admin.setup_token", token)
|
await set_setting(session, "admin.setup_token", token)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_text_replacements(session: AsyncSession, text: str) -> str:
|
||||||
|
"""Apply llm.text_replacements to a text string.
|
||||||
|
|
||||||
|
Each replacement is a dict {from: str, to: str}. The 'from' substring is
|
||||||
|
replaced with 'to' (which can be empty to remove the substring).
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
replacements = await get_setting(session, "llm.text_replacements")
|
||||||
|
if not replacements or not isinstance(replacements, list):
|
||||||
|
return text
|
||||||
|
for r in replacements:
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
continue
|
||||||
|
frm = r.get("from")
|
||||||
|
to = r.get("to", "")
|
||||||
|
if frm and isinstance(frm, str):
|
||||||
|
text = text.replace(frm, to)
|
||||||
|
return text
|
||||||
|
|||||||
@@ -133,6 +133,41 @@ def time_le(a: str, b: str) -> bool:
|
|||||||
return ga.total_minutes() <= gb.total_minutes()
|
return ga.total_minutes() <= gb.total_minutes()
|
||||||
|
|
||||||
|
|
||||||
|
def format_time_human(time_str: str, language: str = "en") -> str:
|
||||||
|
"""Format a game time string as a human-readable localized string.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "day_1_hour_8" → "Day 1, 08:00" (en) / "День 1, 08:00" (ru)
|
||||||
|
- "day_3_hour_14_min_30" → "Day 3, 14:30" (en) / "День 3, 14:30" (ru)
|
||||||
|
- "year_2_day_5_hour_12" → "Year 2, Day 5, 12:00" (en) / "Год 2, День 5, 12:00" (ru)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
gt = GameTime.parse(time_str)
|
||||||
|
except ValueError:
|
||||||
|
return time_str # return as-is if unparseable
|
||||||
|
|
||||||
|
if language == "ru":
|
||||||
|
parts = []
|
||||||
|
if gt.year != 1:
|
||||||
|
parts.append(f"Год {gt.year}")
|
||||||
|
parts.append(f"День {gt.day}")
|
||||||
|
if gt.minute:
|
||||||
|
parts.append(f"{gt.hour:02d}:{gt.minute:02d}")
|
||||||
|
else:
|
||||||
|
parts.append(f"{gt.hour:02d}:00")
|
||||||
|
return ", ".join(parts)
|
||||||
|
else: # en
|
||||||
|
parts = []
|
||||||
|
if gt.year != 1:
|
||||||
|
parts.append(f"Year {gt.year}")
|
||||||
|
parts.append(f"Day {gt.day}")
|
||||||
|
if gt.minute:
|
||||||
|
parts.append(f"{gt.hour:02d}:{gt.minute:02d}")
|
||||||
|
else:
|
||||||
|
parts.append(f"{gt.hour:02d}:00")
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def summarize_schemas(schemas: Iterable[dict]) -> str:
|
def summarize_schemas(schemas: Iterable[dict]) -> str:
|
||||||
"""Render a compact human-readable summary of entity schemas for LLM prompts."""
|
"""Render a compact human-readable summary of entity schemas for LLM prompts."""
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ async def run_iteration(
|
|||||||
if result.ok:
|
if result.ok:
|
||||||
scene_text = result.data.get("scene_text", "")
|
scene_text = result.data.get("scene_text", "")
|
||||||
delta_time = result.data.get("delta_time", "hours_1")
|
delta_time = result.data.get("delta_time", "hours_1")
|
||||||
|
# Apply text replacements
|
||||||
|
from app.core.settings_service import apply_text_replacements
|
||||||
|
scene_text = await apply_text_replacements(db, scene_text)
|
||||||
step.scene_text = scene_text
|
step.scene_text = scene_text
|
||||||
step.scene_delta_time = delta_time
|
step.scene_delta_time = delta_time
|
||||||
await sse.emit("scene_complete", {
|
await sse.emit("scene_complete", {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ _logger = get_logger(__name__)
|
|||||||
class EntityCreateTool(Tool):
|
class EntityCreateTool(Tool):
|
||||||
name = "entity_create"
|
name = "entity_create"
|
||||||
category = "game"
|
category = "game"
|
||||||
stages = {"world_builder", "world_editor", "orchestrator_phase1", "subagent", "intro_scene"}
|
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor", "orchestrator_phase1", "subagent", "intro_scene"}
|
||||||
description = (
|
description = (
|
||||||
"Create a new entity in the current world. The entity_type must exist in "
|
"Create a new entity in the current world. The entity_type must exist in "
|
||||||
"world.schemas. The data must conform to the schema for that type."
|
"world.schemas. The data must conform to the schema for that type."
|
||||||
@@ -800,7 +800,7 @@ class RagAddTool(Tool):
|
|||||||
class SubmitPlanTool(Tool):
|
class SubmitPlanTool(Tool):
|
||||||
name = "submit_plan"
|
name = "submit_plan"
|
||||||
category = "game"
|
category = "game"
|
||||||
stages = {"world_builder", "orchestrator_phase1"}
|
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "orchestrator_phase1"}
|
||||||
description = "End Phase 1. Pass the plan + summary to Phase 2 writer."
|
description = "End Phase 1. Pass the plan + summary to Phase 2 writer."
|
||||||
parameters_schema = {
|
parameters_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -884,7 +884,7 @@ class SuggestActionsTool(Tool):
|
|||||||
class AskUserTool(Tool):
|
class AskUserTool(Tool):
|
||||||
name = "ask_user"
|
name = "ask_user"
|
||||||
category = "interaction"
|
category = "interaction"
|
||||||
stages = {"world_builder", "world_editor"}
|
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||||
description = "Ask the player a clarification question. Blocks until answer."
|
description = "Ask the player a clarification question. Blocks until answer."
|
||||||
parameters_schema = {
|
parameters_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -947,7 +947,7 @@ class ProposeChangesTool(Tool):
|
|||||||
class CommentToUserTool(Tool):
|
class CommentToUserTool(Tool):
|
||||||
name = "comment_to_user"
|
name = "comment_to_user"
|
||||||
category = "interaction"
|
category = "interaction"
|
||||||
stages = {"world_builder", "world_editor"}
|
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||||
description = "Send a text comment to the user (no answer expected)."
|
description = "Send a text comment to the user (no answer expected)."
|
||||||
parameters_schema = {
|
parameters_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ def _find_schema(world_schemas: list[dict], type_name: str) -> dict | None:
|
|||||||
class SchemaAddTypeTool(Tool):
|
class SchemaAddTypeTool(Tool):
|
||||||
name = "schema_add_type"
|
name = "schema_add_type"
|
||||||
category = "schema"
|
category = "schema"
|
||||||
stages = {"world_builder", "world_editor"}
|
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||||
description = "Add a new entity type to world.schemas."
|
description = "Add a new entity type to world.schemas."
|
||||||
parameters_schema = {
|
parameters_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -52,7 +52,7 @@ class SchemaAddTypeTool(Tool):
|
|||||||
class SchemaAddFieldTool(Tool):
|
class SchemaAddFieldTool(Tool):
|
||||||
name = "schema_add_field"
|
name = "schema_add_field"
|
||||||
category = "schema"
|
category = "schema"
|
||||||
stages = {"world_builder", "world_editor"}
|
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||||
description = "Add a field to an existing entity type."
|
description = "Add a field to an existing entity type."
|
||||||
parameters_schema = {
|
parameters_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -86,7 +86,7 @@ class SchemaAddFieldTool(Tool):
|
|||||||
class SchemaRemoveFieldTool(Tool):
|
class SchemaRemoveFieldTool(Tool):
|
||||||
name = "schema_remove_field"
|
name = "schema_remove_field"
|
||||||
category = "schema"
|
category = "schema"
|
||||||
stages = {"world_builder", "world_editor"}
|
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||||
description = "Remove a field from an entity type."
|
description = "Remove a field from an entity type."
|
||||||
parameters_schema = {
|
parameters_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -115,7 +115,7 @@ class SchemaRemoveFieldTool(Tool):
|
|||||||
class SchemaModifyFieldTool(Tool):
|
class SchemaModifyFieldTool(Tool):
|
||||||
name = "schema_modify_field"
|
name = "schema_modify_field"
|
||||||
category = "schema"
|
category = "schema"
|
||||||
stages = {"world_builder", "world_editor"}
|
stages = {"world_builder", "world_builder_schema", "world_builder_env", "world_builder_entities", "world_editor"}
|
||||||
description = "Modify an existing field of an entity type."
|
description = "Modify an existing field of an entity type."
|
||||||
parameters_schema = {
|
parameters_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
Flow (see §9.1 of TDD):
|
Flow (see §9.1 of TDD):
|
||||||
1. Receive template (preset or form).
|
1. Receive template (preset or form).
|
||||||
2. Generate schemas + environment_schema + rules + time_schema.
|
2. Generate schemas + environment_schema + rules + time_schema (via tools or preset).
|
||||||
3. Generate initial environment (player + current_location + plot_rails).
|
3. Generate initial environment (player + current_location + plot_rails) via tools.
|
||||||
4. Generate initial entities (locations, NPCs, items).
|
4. Generate initial entities (locations, NPCs, items) via entity_create tool.
|
||||||
5. Generate intro scene + suggested actions.
|
5. Generate intro scene + suggested actions.
|
||||||
6. Mark world status='ready'.
|
6. Mark world status='ready'.
|
||||||
|
|
||||||
|
Resumability: each stage checks if the world already has the needed data
|
||||||
|
and skips if so. This allows re-running the builder after a failure at any
|
||||||
|
stage without redoing earlier stages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -15,12 +19,13 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.llm import LlmClient, MockLlmClient
|
from app.core.llm import LlmClient, MockLlmClient
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.core.state_validator import validate_world
|
from app.core.state_validator import validate_world
|
||||||
from app.core.time_utils import summarize_schemas
|
from app.core.time_utils import advance_time, summarize_schemas
|
||||||
from app.engine.sse import SseEmitter
|
from app.engine.sse import SseEmitter
|
||||||
from app.engine.tools.base import ToolContext, get_registry
|
from app.engine.tools.base import ToolContext, get_registry
|
||||||
from app.models import World, WorldPreset
|
from app.models import World, WorldPreset
|
||||||
@@ -41,93 +46,80 @@ async def run_world_builder(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Run the full world_builder flow for a draft world.
|
"""Run the full world_builder flow for a draft world.
|
||||||
|
|
||||||
Emits SSE events and updates the world row in place. On error, emits `error`
|
Each stage is resumable: if the world already has the data from a previous
|
||||||
and returns (the world stays in status='draft').
|
run (e.g. schemas exist), that stage is skipped.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# ---- Step 1: Generate schemas / rules / time_schema / environment_schema
|
# ============ Stage 1: Schemas ============
|
||||||
|
# If preset provided AND world already has schemas (from preset), skip.
|
||||||
|
# If no preset, generate schemas via LLM tool-calling.
|
||||||
|
if not world.schemas:
|
||||||
await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."})
|
await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."})
|
||||||
schema_prompt = get_prompt("world_builder_schema", "en").format(
|
|
||||||
mode="preset" if preset else "form",
|
|
||||||
form_data=json.dumps({}, ensure_ascii=False),
|
|
||||||
preset_name=preset.name if preset else "",
|
|
||||||
player_name=player_name,
|
|
||||||
language=world.language,
|
|
||||||
notes=notes or "",
|
|
||||||
)
|
|
||||||
# If we have a preset, use its schemas directly instead of calling LLM
|
|
||||||
if preset and preset.schemas:
|
if preset and preset.schemas:
|
||||||
|
# Use preset schemas directly
|
||||||
world.schemas = preset.schemas
|
world.schemas = preset.schemas
|
||||||
world.environment_schema = preset.environment_schema
|
world.environment_schema = preset.environment_schema
|
||||||
world.rules = preset.rules
|
world.rules = preset.rules
|
||||||
world.time_schema = preset.time_schema
|
world.time_schema = preset.time_schema
|
||||||
world.environment = dict(preset.environment_initial)
|
await db.commit()
|
||||||
else:
|
else:
|
||||||
resp = await llm.complete(
|
# Generate via LLM using schema_add_type tool
|
||||||
stage="world_builder_schema",
|
ok = await _generate_schemas_via_tools(
|
||||||
messages=[{"role": "system", "content": schema_prompt}],
|
db=db, world=world, llm=llm, sse=sse,
|
||||||
temperature=0.5,
|
player_name=player_name, notes=notes,
|
||||||
max_tokens=4096,
|
|
||||||
world_id=world.id,
|
|
||||||
session=db,
|
|
||||||
)
|
)
|
||||||
try:
|
if not ok:
|
||||||
content = resp["message"].get("content", "")
|
await sse.error("schema_generation_failed", "Failed to generate schemas")
|
||||||
# Strip markdown fences if present
|
|
||||||
content = _strip_code_fence(content)
|
|
||||||
schema_data = json.loads(content)
|
|
||||||
except (json.JSONDecodeError, KeyError) as e:
|
|
||||||
await sse.error("schema_generation_failed", f"Invalid JSON from LLM: {e}")
|
|
||||||
return
|
return
|
||||||
world.schemas = schema_data.get("schemas", [])
|
await sse.emit("world_schema_generated", {
|
||||||
world.environment_schema = schema_data.get("environment_schema", [])
|
"schemas": world.schemas, "environment_schema": world.environment_schema,
|
||||||
world.rules = schema_data.get("rules", [])
|
})
|
||||||
world.time_schema = schema_data.get("time_schema", {"hours_in_day": 24, "initial_date": "day_1_hour_8"})
|
else:
|
||||||
world.environment = schema_data.get("environment_initial", {})
|
await sse.emit("step", {"step": "skipping_schema", "message": "Schemas already exist, skipping..."})
|
||||||
|
|
||||||
|
# ============ Stage 2: Environment ============
|
||||||
# Ensure player name is set
|
# Ensure player name is set
|
||||||
env = dict(world.environment or {})
|
env = dict(world.environment or {})
|
||||||
if isinstance(env.get("player"), dict):
|
if isinstance(env.get("player"), dict):
|
||||||
env["player"]["name"] = player_name
|
env["player"]["name"] = player_name
|
||||||
else:
|
else:
|
||||||
env["player"] = {"name": player_name}
|
# If no player, create a minimal one
|
||||||
|
env["player"] = {"name": player_name, "stats": {"health": 100}}
|
||||||
world.environment = env
|
world.environment = env
|
||||||
await db.commit()
|
|
||||||
await sse.emit("world_schema_generated", {
|
|
||||||
"schemas": world.schemas, "environment_schema": world.environment_schema,
|
|
||||||
})
|
|
||||||
|
|
||||||
# ---- Step 2: Generate environment (skip if preset provided one)
|
# Check if environment has current_location and plot_rails
|
||||||
if not preset or not preset.environment_initial:
|
needs_env = (
|
||||||
|
not env.get("current_location")
|
||||||
|
or not env.get("plot_rails")
|
||||||
|
or not (env.get("plot_rails") or {}).get("hooks")
|
||||||
|
)
|
||||||
|
if needs_env and not (preset and preset.environment_initial and env.get("current_location")):
|
||||||
await sse.emit("step", {"step": "generating_environment", "message": "Generating environment..."})
|
await sse.emit("step", {"step": "generating_environment", "message": "Generating environment..."})
|
||||||
env_prompt = get_prompt("world_builder_env", "en").format(
|
if preset and preset.environment_initial and not env.get("current_location"):
|
||||||
world_name=world.name,
|
# Use preset environment but ensure player name
|
||||||
world_description=world.description or "",
|
preset_env = dict(preset.environment_initial)
|
||||||
language=world.language,
|
if isinstance(preset_env.get("player"), dict):
|
||||||
rules="\n".join(f"- {r}" for r in (world.rules or [])),
|
preset_env["player"]["name"] = player_name
|
||||||
schemas_summary=summarize_schemas(world.schemas or []),
|
world.environment = preset_env
|
||||||
environment_schema_json=json.dumps(world.environment_schema, ensure_ascii=False, indent=2),
|
|
||||||
player_name=player_name,
|
|
||||||
)
|
|
||||||
resp = await llm.complete(
|
|
||||||
stage="world_builder_env",
|
|
||||||
messages=[{"role": "system", "content": env_prompt}],
|
|
||||||
temperature=0.6,
|
|
||||||
max_tokens=2048,
|
|
||||||
world_id=world.id,
|
|
||||||
session=db,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
content = _strip_code_fence(resp["message"].get("content", ""))
|
|
||||||
env_data = json.loads(content)
|
|
||||||
env_data.setdefault("player", {}).setdefault("name", player_name)
|
|
||||||
world.environment = env_data
|
|
||||||
except (json.JSONDecodeError, KeyError) as e:
|
|
||||||
await sse.error("env_generation_failed", f"Invalid env JSON: {e}")
|
|
||||||
return
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
else:
|
||||||
|
# Generate via LLM using env_update tool
|
||||||
|
ok = await _generate_environment_via_tools(
|
||||||
|
db=db, world=world, llm=llm, sse=sse, player_name=player_name,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
await sse.error("env_generation_failed", "Failed to generate environment")
|
||||||
|
return
|
||||||
await sse.emit("environment_generated", {"environment": world.environment})
|
await sse.emit("environment_generated", {"environment": world.environment})
|
||||||
|
else:
|
||||||
|
# Ensure plot_rails exists (duplicate to world.plot_rails column)
|
||||||
|
pr = (world.environment or {}).get("plot_rails")
|
||||||
|
if pr:
|
||||||
|
world.plot_rails = pr
|
||||||
|
await db.commit()
|
||||||
|
await sse.emit("step", {"step": "skipping_environment", "message": "Environment already set, skipping..."})
|
||||||
|
|
||||||
# Validate world
|
# Validate world so far
|
||||||
ok, errors = validate_world({
|
ok, errors = validate_world({
|
||||||
"name": world.name, "language": world.language,
|
"name": world.name, "language": world.language,
|
||||||
"schemas": world.schemas, "environment_schema": world.environment_schema,
|
"schemas": world.schemas, "environment_schema": world.environment_schema,
|
||||||
@@ -135,10 +127,25 @@ async def run_world_builder(
|
|||||||
"current_time": world.current_time,
|
"current_time": world.current_time,
|
||||||
})
|
})
|
||||||
if not ok:
|
if not ok:
|
||||||
await sse.error("world_invalid", "World validation failed", details=errors)
|
# Don't fail — log and continue, the world may still be usable
|
||||||
return
|
_logger.warning("world_validation_partial", world_id=str(world.id), errors=errors)
|
||||||
|
await sse.emit("warning", {
|
||||||
|
"code": "validation_warnings",
|
||||||
|
"message": "World has validation issues: " + "; ".join(errors[:3]),
|
||||||
|
})
|
||||||
|
|
||||||
# ---- Step 3: Generate entities via tool-calling loop
|
# ============ Stage 3: Entities ============
|
||||||
|
# Check if world already has entities
|
||||||
|
from app.models import Entity
|
||||||
|
|
||||||
|
existing_entities = (
|
||||||
|
await db.execute(
|
||||||
|
select(Entity).where(
|
||||||
|
Entity.world_id == world.id, Entity.deleted_at.is_(None)
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if not existing_entities:
|
||||||
await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."})
|
await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."})
|
||||||
await _run_tool_loop(
|
await _run_tool_loop(
|
||||||
db=db, world=world, llm=llm, sse=sse,
|
db=db, world=world, llm=llm, sse=sse,
|
||||||
@@ -149,21 +156,20 @@ async def run_world_builder(
|
|||||||
language=world.language,
|
language=world.language,
|
||||||
schemas_summary=summarize_schemas(world.schemas or []),
|
schemas_summary=summarize_schemas(world.schemas or []),
|
||||||
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
|
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
|
||||||
max_substeps=12,
|
max_substeps=15,
|
||||||
),
|
),
|
||||||
terminal_tool="submit_plan",
|
terminal_tool="submit_plan",
|
||||||
max_substeps=12,
|
max_substeps=15,
|
||||||
settings={}, # world_builder uses fixed defaults
|
settings={},
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await sse.emit("entities_generated", {"world_id": str(world.id)})
|
await sse.emit("entities_generated", {"world_id": str(world.id)})
|
||||||
|
else:
|
||||||
|
await sse.emit("step", {"step": "skipping_entities", "message": "Entities already exist, skipping..."})
|
||||||
|
|
||||||
# ---- Step 4: Generate intro scene
|
# ============ Stage 4: Intro scene ============
|
||||||
|
if not world.intro_scene:
|
||||||
await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
|
await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.models import Entity
|
|
||||||
|
|
||||||
entities = (
|
entities = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Entity).where(
|
select(Entity).where(
|
||||||
@@ -173,21 +179,19 @@ async def run_world_builder(
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
entities_summary = "\n".join(
|
entities_summary = "\n".join(
|
||||||
f"- {e.entity_type}: {e.name}" for e in entities[:20]
|
f"- {e.entity_type}: {e.name}" for e in entities[:20]
|
||||||
)
|
) or "(no entities)"
|
||||||
intro_prompt = get_prompt("intro_scene", "en").format(
|
scene_result = await _run_tool_loop(
|
||||||
|
db=db, world=world, llm=llm, sse=sse,
|
||||||
|
stage="intro_scene",
|
||||||
|
system_prompt=get_prompt("intro_scene", "en").format(
|
||||||
world_name=world.name,
|
world_name=world.name,
|
||||||
world_description=world.description or "",
|
world_description=world.description or "",
|
||||||
language=world.language,
|
language=world.language,
|
||||||
current_time=world.current_time,
|
current_time=world.current_time,
|
||||||
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
|
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
|
||||||
plot_rails_json=json.dumps(world.plot_rails, ensure_ascii=False, indent=2),
|
plot_rails_json=json.dumps(world.plot_rails or {}, ensure_ascii=False, indent=2),
|
||||||
entities_summary=entities_summary,
|
entities_summary=entities_summary,
|
||||||
)
|
),
|
||||||
# Phase 2: scene_text + delta_time
|
|
||||||
scene_result = await _run_tool_loop(
|
|
||||||
db=db, world=world, llm=llm, sse=sse,
|
|
||||||
stage="intro_scene",
|
|
||||||
system_prompt=intro_prompt,
|
|
||||||
terminal_tool="submit_step",
|
terminal_tool="submit_step",
|
||||||
max_substeps=3,
|
max_substeps=3,
|
||||||
settings={},
|
settings={},
|
||||||
@@ -197,14 +201,19 @@ async def run_world_builder(
|
|||||||
if scene_result and scene_result.get("ok"):
|
if scene_result and scene_result.get("ok"):
|
||||||
scene_text = scene_result.get("data", {}).get("scene_text", "")
|
scene_text = scene_result.get("data", {}).get("scene_text", "")
|
||||||
delta_time = scene_result.get("data", {}).get("delta_time", "hours_1")
|
delta_time = scene_result.get("data", {}).get("delta_time", "hours_1")
|
||||||
|
# Apply text replacements
|
||||||
|
if scene_text:
|
||||||
|
from app.core.settings_service import apply_text_replacements
|
||||||
|
scene_text = await apply_text_replacements(db, scene_text)
|
||||||
|
if scene_text:
|
||||||
world.intro_scene = scene_text
|
world.intro_scene = scene_text
|
||||||
from app.core.time_utils import advance_time
|
|
||||||
|
|
||||||
world.current_time = advance_time(world.current_time, delta_time, world.time_schema)
|
world.current_time = advance_time(world.current_time, delta_time, world.time_schema)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await sse.emit("intro_scene_complete", {
|
await sse.emit("intro_scene_complete", {
|
||||||
"text": scene_text, "delta_time": delta_time, "current_time": world.current_time,
|
"text": scene_text, "delta_time": delta_time, "current_time": world.current_time,
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
|
await sse.emit("step", {"step": "skipping_intro", "message": "Intro scene already exists, skipping..."})
|
||||||
|
|
||||||
# Mark ready
|
# Mark ready
|
||||||
world.status = "ready"
|
world.status = "ready"
|
||||||
@@ -215,11 +224,76 @@ async def run_world_builder(
|
|||||||
await sse.error("internal_error", str(e))
|
await sse.error("internal_error", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_schemas_via_tools(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
world: World,
|
||||||
|
llm: LlmClient | MockLlmClient,
|
||||||
|
sse: SseEmitter,
|
||||||
|
player_name: str,
|
||||||
|
notes: str | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Generate world schemas by having the LLM call schema_add_type tools."""
|
||||||
|
prompt = get_prompt("world_builder_schema", "en").format(
|
||||||
|
mode="form",
|
||||||
|
form_data="{}",
|
||||||
|
preset_name="",
|
||||||
|
player_name=player_name,
|
||||||
|
language=world.language,
|
||||||
|
notes=notes or "",
|
||||||
|
)
|
||||||
|
result = await _run_tool_loop(
|
||||||
|
db=db, world=world, llm=llm, sse=sse,
|
||||||
|
stage="world_builder_schema",
|
||||||
|
system_prompt=prompt,
|
||||||
|
terminal_tool="submit_plan",
|
||||||
|
max_substeps=10,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
# After tool loop, check if schemas were created
|
||||||
|
await db.refresh(world)
|
||||||
|
return bool(world.schemas)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_environment_via_tools(
|
||||||
|
*,
|
||||||
|
db: AsyncSession,
|
||||||
|
world: World,
|
||||||
|
llm: LlmClient | MockLlmClient,
|
||||||
|
sse: SseEmitter,
|
||||||
|
player_name: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Generate environment by having the LLM call env_update tools."""
|
||||||
|
prompt = get_prompt("world_builder_env", "en").format(
|
||||||
|
world_name=world.name,
|
||||||
|
world_description=world.description or "",
|
||||||
|
language=world.language,
|
||||||
|
rules="\n".join(f"- {r}" for r in (world.rules or [])),
|
||||||
|
schemas_summary=summarize_schemas(world.schemas or []),
|
||||||
|
environment_schema_json=json.dumps(world.environment_schema, ensure_ascii=False, indent=2),
|
||||||
|
player_name=player_name,
|
||||||
|
)
|
||||||
|
result = await _run_tool_loop(
|
||||||
|
db=db, world=world, llm=llm, sse=sse,
|
||||||
|
stage="world_builder_env",
|
||||||
|
system_prompt=prompt,
|
||||||
|
terminal_tool="submit_plan",
|
||||||
|
max_substeps=10,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
await db.refresh(world)
|
||||||
|
env = world.environment or {}
|
||||||
|
# Sync plot_rails
|
||||||
|
if isinstance(env.get("plot_rails"), dict):
|
||||||
|
world.plot_rails = env["plot_rails"]
|
||||||
|
await db.commit()
|
||||||
|
return bool(env.get("current_location"))
|
||||||
|
|
||||||
|
|
||||||
def _strip_code_fence(text: str) -> str:
|
def _strip_code_fence(text: str) -> str:
|
||||||
"""Remove ```json ... ``` fences if present."""
|
"""Remove ```json ... ``` fences if present."""
|
||||||
s = text.strip()
|
s = text.strip()
|
||||||
if s.startswith("```"):
|
if s.startswith("```"):
|
||||||
# Remove first line (``` or ```json)
|
|
||||||
s = s.split("\n", 1)[1] if "\n" in s else s
|
s = s.split("\n", 1)[1] if "\n" in s else s
|
||||||
if s.endswith("```"):
|
if s.endswith("```"):
|
||||||
s = s[:-3]
|
s = s[:-3]
|
||||||
@@ -246,7 +320,7 @@ async def _run_tool_loop(
|
|||||||
)
|
)
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": f"Begin {stage}."},
|
{"role": "user", "content": f"Begin {stage}. Use the available tools to accomplish the task. When done, call {terminal_tool}."},
|
||||||
]
|
]
|
||||||
tools = registry.to_openai_format(stage)
|
tools = registry.to_openai_format(stage)
|
||||||
last_terminal_result: dict[str, Any] | None = None
|
last_terminal_result: dict[str, Any] | None = None
|
||||||
@@ -273,20 +347,19 @@ async def _run_tool_loop(
|
|||||||
messages.append({"role": "assistant", "content": msg.get("content", "")})
|
messages.append({"role": "assistant", "content": msg.get("content", "")})
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": "You must call a tool. Available terminal tool: " + terminal_tool,
|
"content": f"You must call a tool. Available terminal tool: {terminal_tool}. If you are done with your work, call {terminal_tool} now.",
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
fn = tc.get("function", {})
|
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
|
||||||
tname = fn.get("name", "")
|
tname = fn.get("name", "")
|
||||||
try:
|
try:
|
||||||
targs = json.loads(fn.get("arguments") or "{}")
|
targs = json.loads(fn.get("arguments") or "{}")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
targs = {}
|
targs = {}
|
||||||
result = await registry.execute(tname, targs, ctx)
|
result = await registry.execute(tname, targs, ctx)
|
||||||
# Tool result as a tool message
|
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tc.get("id", ""),
|
"tool_call_id": tc.get("id", ""),
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ async def run_world_editor(
|
|||||||
for tc in tcs:
|
for tc in tcs:
|
||||||
fn = tc.get("function") or {}
|
fn = tc.get("function") or {}
|
||||||
if not isinstance(fn, dict):
|
if not isinstance(fn, dict):
|
||||||
# Some models put the function name/args directly on tc
|
|
||||||
fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")}
|
fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")}
|
||||||
tname = fn.get("name", "")
|
tname = fn.get("name", "")
|
||||||
args_str = fn.get("arguments", "{}")
|
args_str = fn.get("arguments", "{}")
|
||||||
@@ -136,7 +135,6 @@ async def run_world_editor(
|
|||||||
"question": targs.get("question", ""),
|
"question": targs.get("question", ""),
|
||||||
"options": targs.get("options"),
|
"options": targs.get("options"),
|
||||||
})
|
})
|
||||||
# Wait for user answer via REST
|
|
||||||
fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
|
fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
|
||||||
_pending_clarifications[world.id] = fut
|
_pending_clarifications[world.id] = fut
|
||||||
try:
|
try:
|
||||||
@@ -161,7 +159,6 @@ async def run_world_editor(
|
|||||||
"diff": diff,
|
"diff": diff,
|
||||||
"comment": comment,
|
"comment": comment,
|
||||||
})
|
})
|
||||||
# Wait for user accept/reject via REST
|
|
||||||
fut2: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
|
fut2: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
|
||||||
_pending_changes[world.id] = fut2
|
_pending_changes[world.id] = fut2
|
||||||
try:
|
try:
|
||||||
@@ -176,21 +173,12 @@ async def run_world_editor(
|
|||||||
await _apply_diff(world, diff)
|
await _apply_diff(world, diff)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await sse.emit("apply_changes", {"diff": diff})
|
await sse.emit("apply_changes", {"diff": diff})
|
||||||
messages.append({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tc.get("id", ""),
|
|
||||||
"name": tname,
|
|
||||||
"content": json.dumps({"ok": True, "applied": True}),
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
await sse.emit("discard_changes", {})
|
await sse.emit("discard_changes", {})
|
||||||
messages.append({
|
# After propose_changes (accepted or rejected), END the loop.
|
||||||
"role": "tool",
|
# Don't make another LLM call — the user's decision is final.
|
||||||
"tool_call_id": tc.get("id", ""),
|
done = True
|
||||||
"name": tname,
|
break
|
||||||
"content": json.dumps({"ok": True, "applied": False, "reason": "user_rejected"}),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
# Regular tool — execute
|
# Regular tool — execute
|
||||||
result = await registry.execute(tname, targs, ctx)
|
result = await registry.execute(tname, targs, ctx)
|
||||||
messages.append({
|
messages.append({
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
"""System prompt for stage `world_builder_env` — generates the initial environment."""
|
"""System prompt for stage `world_builder_env` — generates the initial environment via env_update tool."""
|
||||||
|
|
||||||
PROMPTS = {
|
PROMPTS = {
|
||||||
"en": """You are the World Builder for an AI-driven text RPG.
|
"en": """You are the World Builder for an AI-driven text RPG.
|
||||||
|
|
||||||
Your task: produce the initial `environment` JSON for a world whose schema has
|
Your task: set up the initial environment for the world by calling the `env_update` tool.
|
||||||
already been generated.
|
|
||||||
|
|
||||||
The environment must include:
|
The environment must include:
|
||||||
- "player": a character object matching the `character` schema. The player's name is
|
- `player`: a character object. The player's name is `{player_name}`. Give them starting stats (health=100, mana=10, strength=10), an empty inventory, and a short backstory (1-2 sentences).
|
||||||
`{player_name}`. Give them starting stats (health=100, mana=10, strength=10),
|
- `current_location`: a string naming the starting location (e.g. "The Rusty Tankard Tavern" or "Station Command Module").
|
||||||
an empty inventory, and a short backstory (1-2 sentences).
|
- `plot_rails`: an object with `hooks` (array of 2 short story hooks), `current_goals` (array of 1 starting goal), and `completed_goals` (empty array).
|
||||||
- "current_location": a string naming the starting location (it must match the
|
|
||||||
name of one of the locations generated in the next step — for now just pick a
|
# How to use env_update
|
||||||
thematic starting place like "Tavern" or "Camp").
|
Call env_update with a `patch` argument. The patch is a dict of field_path -> value.
|
||||||
- "plot_rails": {{"hooks": [<2 short story hooks>], "current_goals": [<1 starting goal>],
|
To set nested fields, use dotted paths.
|
||||||
"completed_goals": []}}
|
|
||||||
- Any other fields declared in environment_schema.
|
Example:
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"patch": {{
|
||||||
|
"player": {{"name": "{player_name}", "stats": {{"health": 100, "mana": 10, "strength": 10}}, "inventory": [], "backstory": "..."}},
|
||||||
|
"current_location": "Tavern",
|
||||||
|
"plot_rails": {{"hooks": ["hook1", "hook2"], "current_goals": ["goal1"], "completed_goals": []}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can either set everything in one env_update call, or make multiple calls (one for player, one for current_location, one for plot_rails).
|
||||||
|
|
||||||
# World context
|
# World context
|
||||||
World name: {world_name}
|
World name: {world_name}
|
||||||
@@ -30,7 +40,12 @@ Schemas:
|
|||||||
Environment schema:
|
Environment schema:
|
||||||
{environment_schema_json}
|
{environment_schema_json}
|
||||||
|
|
||||||
# Output
|
# After setting up the environment
|
||||||
Return ONLY a JSON object. No commentary. The output must conform to environment_schema.
|
Call `submit_plan` with a 1-sentence summary.
|
||||||
|
|
||||||
|
# Rules
|
||||||
|
- Use env_update to set the environment fields.
|
||||||
|
- After all env_update calls, call submit_plan exactly once.
|
||||||
|
- After max 10 tool calls you MUST call submit_plan.
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,9 @@
|
|||||||
"""System prompt for stage `world_builder_schema` — generates the world's schemas."""
|
"""System prompt for stage `world_builder_schema` — generates the world's schemas via tools."""
|
||||||
|
|
||||||
PROMPTS = {
|
PROMPTS = {
|
||||||
"en": """You are the World Builder for an AI-driven text RPG.
|
"en": """You are the World Builder for an AI-driven text RPG.
|
||||||
|
|
||||||
Your task: produce the JSON schema for a new world based on the player's request.
|
Your task: define the entity schemas for a new world by calling the `schema_add_type` tool for each entity type you want to create.
|
||||||
|
|
||||||
Output a JSON object with keys:
|
|
||||||
- "name": short world name
|
|
||||||
- "description": 2-3 sentence world premise
|
|
||||||
- "language": ISO code (e.g. "en", "ru") — must match the player's requested language
|
|
||||||
- "rules": array of short rule strings the GM must follow
|
|
||||||
- "time_schema": {{"hours_in_day": 24, "initial_date": "day_1_hour_8"}}
|
|
||||||
- "schemas": array of entity-type definitions, each shaped as
|
|
||||||
{{"type": "character", "verbose": "Character", "plural": "characters",
|
|
||||||
"properties": [
|
|
||||||
{{"name": "name", "type": "string", "required": true}},
|
|
||||||
{{"name": "stats", "type": "object", "required": true,
|
|
||||||
"properties": [
|
|
||||||
{{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100}},
|
|
||||||
{{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100}},
|
|
||||||
{{"name": "strength","type": "integer", "required": true, "min": 1, "max": 20}}
|
|
||||||
]}}
|
|
||||||
]}}
|
|
||||||
Include at minimum: character (with stats.health, stats.mana, stats.strength,
|
|
||||||
inventory array of items), item, location, faction.
|
|
||||||
- "environment_schema": array of top-level environment fields
|
|
||||||
(e.g. player:object, current_location:string, plot_rails:object)
|
|
||||||
- "environment_initial": initial environment JSON (with player empty, current_location empty,
|
|
||||||
plot_rails with empty arrays)
|
|
||||||
|
|
||||||
# Player request
|
# Player request
|
||||||
Mode: {mode}
|
Mode: {mode}
|
||||||
@@ -37,12 +13,40 @@ Player name: {player_name}
|
|||||||
Language: {language}
|
Language: {language}
|
||||||
Notes: {notes}
|
Notes: {notes}
|
||||||
|
|
||||||
# Rules for output
|
# Required entity types
|
||||||
- Return ONLY a JSON object. No commentary.
|
You MUST create at minimum these entity types (call schema_add_type for each):
|
||||||
- Keep schemas small (3-6 fields per type).
|
1. `character` — with fields: name (string, required), description (string), stats (object, required, with sub-fields: health integer 0-100 required, mana integer 0-100, strength integer 1-20), inventory (array), relationship (string)
|
||||||
- "stats.health" must be integer with min=0 max=100.
|
2. `item` — with fields: name (string, required), description (string), qty (integer 1-9999), value (integer)
|
||||||
- Always include `player` (character) and `current_location` (string) in environment_schema.
|
3. `location` — with fields: name (string, required), description (string, required), exits (array of strings), is_safe (boolean)
|
||||||
- The world is for a 7B-parameter LLM — keep schemas readable.
|
4. `faction` — with fields: name (string, required), description (string), alignment (string)
|
||||||
|
|
||||||
|
You may add additional entity types if the setting requires (e.g. `quest`, `spell`, `vehicle`).
|
||||||
|
|
||||||
|
# schema_add_type arguments
|
||||||
|
Each call to schema_add_type needs:
|
||||||
|
- `type`: lowercase identifier like "character"
|
||||||
|
- `verbose`: display name like "Character"
|
||||||
|
- `plural`: plural form like "characters"
|
||||||
|
- `properties`: array of field definitions, each with {name, type, required, min, max, properties}
|
||||||
|
|
||||||
|
Example properties for a character type:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{{"name": "name", "type": "string", "required": true}},
|
||||||
|
{{"name": "stats", "type": "object", "required": true, "properties": [
|
||||||
|
{{"name": "health", "type": "integer", "required": true, "min": 0, "max": 100}},
|
||||||
|
{{"name": "mana", "type": "integer", "required": false, "min": 0, "max": 100}}
|
||||||
|
]}}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
# After creating all schemas
|
||||||
|
Call `submit_plan` with a 1-sentence summary of the world you designed.
|
||||||
|
|
||||||
|
# Rules
|
||||||
|
- Call schema_add_type for EACH entity type (one call per type).
|
||||||
|
- After all schema_add_type calls, call submit_plan exactly once.
|
||||||
|
- After max 10 tool calls you MUST call submit_plan.
|
||||||
""",
|
""",
|
||||||
"ru": "", # legacy — English is the source of truth per §10.1
|
"ru": "",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
|||||||
import { LoginPage } from "@/pages/LoginPage";
|
import { LoginPage } from "@/pages/LoginPage";
|
||||||
import { RegisterPage } from "@/pages/RegisterPage";
|
import { RegisterPage } from "@/pages/RegisterPage";
|
||||||
import { AdminRegisterPage } from "@/pages/AdminRegisterPage";
|
import { AdminRegisterPage } from "@/pages/AdminRegisterPage";
|
||||||
|
import { AdminRecoverPage } from "@/pages/AdminRecoverPage";
|
||||||
import { WorldsListPage } from "@/pages/WorldsListPage";
|
import { WorldsListPage } from "@/pages/WorldsListPage";
|
||||||
import { WorldBuilderPage } from "@/pages/WorldBuilderPage";
|
import { WorldBuilderPage } from "@/pages/WorldBuilderPage";
|
||||||
import { WorldEditPage } from "@/pages/WorldEditPage";
|
import { WorldEditPage } from "@/pages/WorldEditPage";
|
||||||
@@ -109,6 +110,16 @@ export default function App() {
|
|||||||
</PublicOnly>
|
</PublicOnly>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Disaster-recovery admin creation — PUBLIC (no auth), not behind
|
||||||
|
ProtectedRoute. Used when all admins have lost access. */}
|
||||||
|
<Route
|
||||||
|
path="/recover"
|
||||||
|
element={
|
||||||
|
<Layout>
|
||||||
|
<AdminRecoverPage />
|
||||||
|
</Layout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Protected routes */}
|
{/* Protected routes */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ export function LlmLogsTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Short preview of the world_id filter value, for the column header.
|
||||||
|
const worldFilterPreview = appliedFilters.world_id
|
||||||
|
? appliedFilters.world_id.slice(0, 8)
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card title={t("admin.tab_logs")}>
|
<Card title={t("admin.tab_logs")}>
|
||||||
@@ -149,6 +154,14 @@ export function LlmLogsTable() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-fg-dim/20 text-left text-xs uppercase text-fg-muted">
|
<tr className="border-b border-fg-dim/20 text-left text-xs uppercase text-fg-muted">
|
||||||
<th className="p-2">{t("admin.logs_stage")}</th>
|
<th className="p-2">{t("admin.logs_stage")}</th>
|
||||||
|
<th className="p-2">
|
||||||
|
{t("admin.logs_world")}
|
||||||
|
{worldFilterPreview && (
|
||||||
|
<span className="ml-1 normal-case text-fg-dim">
|
||||||
|
({t("admin.logs_filtered")}: {worldFilterPreview})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
<th className="p-2">{t("admin.logs_status")}</th>
|
<th className="p-2">{t("admin.logs_status")}</th>
|
||||||
<th className="p-2">{t("admin.logs_latency")}</th>
|
<th className="p-2">{t("admin.logs_latency")}</th>
|
||||||
<th className="p-2">{t("admin.logs_tokens")}</th>
|
<th className="p-2">{t("admin.logs_tokens")}</th>
|
||||||
@@ -160,6 +173,9 @@ export function LlmLogsTable() {
|
|||||||
{data.items.map((log) => (
|
{data.items.map((log) => (
|
||||||
<tr key={log.id} className="border-b border-fg-dim/10 hover:bg-bg-soft">
|
<tr key={log.id} className="border-b border-fg-dim/10 hover:bg-bg-soft">
|
||||||
<td className="p-2 font-mono text-xs">{log.stage}</td>
|
<td className="p-2 font-mono text-xs">{log.stage}</td>
|
||||||
|
<td className="p-2 font-mono text-xs text-fg-muted">
|
||||||
|
{log.world_id ? log.world_id.slice(0, 8) : "—"}
|
||||||
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<span className={`badge ${statusColor(log.status)}`}>
|
<span className={`badge ${statusColor(log.status)}`}>
|
||||||
{log.status}
|
{log.status}
|
||||||
@@ -230,6 +246,7 @@ export function LlmLogsTable() {
|
|||||||
badgeClass={statusColor(detail.status)}
|
badgeClass={statusColor(detail.status)}
|
||||||
/>
|
/>
|
||||||
<Field label={t("admin.model")} value={detail.model || "—"} />
|
<Field label={t("admin.model")} value={detail.model || "—"} />
|
||||||
|
<Field label={t("admin.logs_world")} value={detail.world_id || "—"} />
|
||||||
<Field
|
<Field
|
||||||
label={t("admin.logs_latency")}
|
label={t("admin.logs_latency")}
|
||||||
value={detail.latency_ms != null ? `${detail.latency_ms} ms` : "—"}
|
value={detail.latency_ms != null ? `${detail.latency_ms} ms` : "—"}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AdminApi } from "@/lib/api";
|
import { AdminApi, ApiError } from "@/lib/api";
|
||||||
import { useToastStore } from "@/stores/toastStore";
|
import { useToastStore } from "@/stores/toastStore";
|
||||||
import { refreshUiSettings } from "@/stores/uiSettingsStore";
|
import { refreshUiSettings } from "@/stores/uiSettingsStore";
|
||||||
import type { AdminSettingsResponse } from "@/types";
|
import type { AdminSettingsResponse } from "@/types";
|
||||||
@@ -33,6 +33,11 @@ function groupFor(key: string): GroupDef | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Setting keys rendered with a specialized UI rather than the generic
|
||||||
|
* text/integer/boolean row. They are filtered out of the regular field
|
||||||
|
* list and rendered separately. */
|
||||||
|
const SPECIAL_KEYS = new Set<string>(["llm.model", "llm.text_replacements"]);
|
||||||
|
|
||||||
/** Field types — drives which control is rendered. */
|
/** Field types — drives which control is rendered. */
|
||||||
type FieldType = "integer" | "float" | "boolean" | "provider" | "secret" | "text";
|
type FieldType = "integer" | "float" | "boolean" | "provider" | "secret" | "text";
|
||||||
|
|
||||||
@@ -143,6 +148,9 @@ export function SettingsPanel() {
|
|||||||
for (const key of Object.keys(data.settings)) {
|
for (const key of Object.keys(data.settings)) {
|
||||||
const g = groupFor(key);
|
const g = groupFor(key);
|
||||||
if (!g) continue;
|
if (!g) continue;
|
||||||
|
// Special-rendered keys are filtered out of the regular list — they
|
||||||
|
// get their own dedicated UI (model fetcher, text replacements).
|
||||||
|
if (SPECIAL_KEYS.has(key)) continue;
|
||||||
(out[g.id] ||= []).push({ key, description: data.descriptions?.[key] });
|
(out[g.id] ||= []).push({ key, description: data.descriptions?.[key] });
|
||||||
}
|
}
|
||||||
// Sort each group's keys alphabetically for stable display.
|
// Sort each group's keys alphabetically for stable display.
|
||||||
@@ -199,6 +207,30 @@ export function SettingsPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a single setting key (used by the Text Replacements card, which
|
||||||
|
* saves only `llm.text_replacements`). No casting is applied — the value
|
||||||
|
* is stored as-is (a JSON string).
|
||||||
|
*/
|
||||||
|
const handleSaveKey = async (key: string, value: string) => {
|
||||||
|
if (!data) return;
|
||||||
|
const before = data.settings[key] ?? "";
|
||||||
|
if (before === value) {
|
||||||
|
pushToast("info", "No changes to save.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await AdminApi.updateSettings({ [key]: value });
|
||||||
|
const nextSettings = { ...data.settings, [key]: res.updated[key] };
|
||||||
|
setData({ ...data, settings: nextSettings });
|
||||||
|
setDraft((d) => ({ ...d, [key]: res.updated[key] }));
|
||||||
|
pushToast("success", t("admin.settings_saved"));
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Failed to save settings";
|
||||||
|
pushToast("error", msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
@@ -236,18 +268,30 @@ export function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
{GROUPS.map((g) => {
|
{GROUPS.map((g) => {
|
||||||
const entries = grouped[g.id];
|
const entries = grouped[g.id];
|
||||||
if (!entries || entries.length === 0) return null;
|
// LLM group always renders even if api_url/api_key/etc. are missing
|
||||||
|
// from the backend response — the specialized sub-cards (model
|
||||||
|
// fetcher, text replacements) live here. For other groups, skip
|
||||||
|
// when empty.
|
||||||
|
if ((!entries || entries.length === 0) && g.id !== "llm") return null;
|
||||||
return (
|
return (
|
||||||
|
<Fragment key={g.id}>
|
||||||
<SettingsGroupCard
|
<SettingsGroupCard
|
||||||
key={g.id}
|
groupId={g.id}
|
||||||
title={t(g.labelKey)}
|
title={t(g.labelKey)}
|
||||||
entries={entries}
|
entries={entries || []}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
onChange={(key, value) =>
|
onChange={(key, value) =>
|
||||||
setDraft((d) => ({ ...d, [key]: value }))
|
setDraft((d) => ({ ...d, [key]: value }))
|
||||||
}
|
}
|
||||||
onSave={() => void handleSaveGroup(g.id)}
|
onSave={() => void handleSaveGroup(g.id)}
|
||||||
/>
|
/>
|
||||||
|
{g.id === "llm" && (
|
||||||
|
<TextReplacementsCard
|
||||||
|
rawValue={draft["llm.text_replacements"] ?? ""}
|
||||||
|
onSave={(v) => void handleSaveKey("llm.text_replacements", v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -255,6 +299,7 @@ export function SettingsPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsGroupCardProps {
|
interface SettingsGroupCardProps {
|
||||||
|
groupId: string;
|
||||||
title: string;
|
title: string;
|
||||||
entries: Array<{ key: string; description?: string }>;
|
entries: Array<{ key: string; description?: string }>;
|
||||||
draft: Record<string, string>;
|
draft: Record<string, string>;
|
||||||
@@ -262,7 +307,7 @@ interface SettingsGroupCardProps {
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsGroupCard({ title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
|
function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -273,6 +318,17 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// The "llm.model" key is rendered as a full-width specialized field with
|
||||||
|
// a "Fetch models" button — pull it out of the regular grid flow when
|
||||||
|
// present so it can span both columns.
|
||||||
|
const modelEntry = groupId === "llm"
|
||||||
|
? (entries.find((e) => e.key === "llm.model") ?? (draft["llm.model"] !== undefined ? { key: "llm.model" } : null))
|
||||||
|
: null;
|
||||||
|
const regularEntries = entries.filter((e) => e.key !== "llm.model");
|
||||||
|
// Always render the LLM group even if only `llm.model` is present, since
|
||||||
|
// the model field is special.
|
||||||
|
const showGrid = regularEntries.length > 0;
|
||||||
|
void groupId; // groupId currently used only for the model-entry lookup above
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={title}
|
title={title}
|
||||||
@@ -282,8 +338,10 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{showGrid && (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{entries.map(({ key, description }) => (
|
{regularEntries.map(({ key, description }) => (
|
||||||
<SettingField
|
<SettingField
|
||||||
key={key}
|
key={key}
|
||||||
settingKey={key}
|
settingKey={key}
|
||||||
@@ -293,6 +351,17 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{modelEntry && (
|
||||||
|
<LlmModelField
|
||||||
|
value={draft["llm.model"] ?? ""}
|
||||||
|
onChange={(v) => onChange("llm.model", v)}
|
||||||
|
apiUrl={draft["llm.api_url"] ?? ""}
|
||||||
|
apiKey={draft["llm.api_key"] ?? ""}
|
||||||
|
description={modelEntry.description}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -411,3 +480,245 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LLM model field — text input + "Fetch models" button + dropdown.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface LlmModelFieldProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specialized renderer for the `llm.model` setting. Shows a text input
|
||||||
|
* (where the user can type any model name) plus a "Fetch models" button
|
||||||
|
* that probes the configured provider and, on success, renders a dropdown
|
||||||
|
* of available models below the input. Selecting from the dropdown fills
|
||||||
|
* the text input. The fetched list is cached in component state — it is
|
||||||
|
* only re-fetched when the button is clicked.
|
||||||
|
*/
|
||||||
|
function LlmModelField({ value, onChange, apiUrl, apiKey, description }: LlmModelFieldProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [models, setModels] = useState<string[] | null>(null);
|
||||||
|
const [fetchError, setFetchError] = useState(false);
|
||||||
|
const hint = description;
|
||||||
|
|
||||||
|
const handleFetch = async () => {
|
||||||
|
setFetching(true);
|
||||||
|
setFetchError(false);
|
||||||
|
try {
|
||||||
|
const res = await AdminApi.listLlmModels(apiUrl || undefined, apiKey || undefined);
|
||||||
|
if (res.ok && Array.isArray(res.models)) {
|
||||||
|
setModels(res.models);
|
||||||
|
} else {
|
||||||
|
setModels([]);
|
||||||
|
setFetchError(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ApiError carries a message from the backend (e.g. provider down,
|
||||||
|
// bad key). We don't show it inline — the muted hint is enough —
|
||||||
|
// but we do log it for debugging.
|
||||||
|
setModels([]);
|
||||||
|
setFetchError(true);
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn("listLlmModels failed:", err.message, err.details);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setFetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<label className="label" htmlFor="setting-llm.model">llm.model</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="setting-llm.model"
|
||||||
|
className="input flex-1"
|
||||||
|
autoComplete="off"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="gpt-4o-mini"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleFetch}
|
||||||
|
loading={fetching}
|
||||||
|
disabled={fetching}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{t("admin.fetch_models")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
|
||||||
|
{models && models.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="label text-xs" htmlFor="llm-model-select">
|
||||||
|
{t("admin.fetched_models")} ({models.length})
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="llm-model-select"
|
||||||
|
className="input"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) onChange(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{t("admin.pick_model")}
|
||||||
|
</option>
|
||||||
|
{models.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fetchError && (
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{t("admin.fetch_models_failed")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Text Replacements card.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TextReplacementRule {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextReplacementsCardProps {
|
||||||
|
rawValue: string;
|
||||||
|
onSave: (serializedJson: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the persisted JSON string into a list of rules. Tolerates
|
||||||
|
* malformed / empty input by returning an empty list. */
|
||||||
|
function parseReplacements(raw: string): TextReplacementRule[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.map((item): TextReplacementRule | null => {
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
const obj = item as { from?: unknown; to?: unknown };
|
||||||
|
return {
|
||||||
|
from: typeof obj.from === "string" ? obj.from : "",
|
||||||
|
to: typeof obj.to === "string" ? obj.to : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((x): x is TextReplacementRule => x !== null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeReplacements(rules: TextReplacementRule[]): string {
|
||||||
|
return JSON.stringify(rules.map((r) => ({ from: r.from, to: r.to })));
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Local working copy — only committed to parent draft when Save is
|
||||||
|
// clicked. This avoids marking the LLM group as dirty on every keystroke.
|
||||||
|
const [rules, setRules] = useState<TextReplacementRule[]>(() => parseReplacements(rawValue));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Re-sync from the persisted value if it changes externally (e.g. after
|
||||||
|
// a successful save the parent passes back the masked value, which for
|
||||||
|
// this key is the same JSON we just wrote).
|
||||||
|
useEffect(() => {
|
||||||
|
setRules(parseReplacements(rawValue));
|
||||||
|
}, [rawValue]);
|
||||||
|
|
||||||
|
const dirty = serializeReplacements(rules) !== rawValue;
|
||||||
|
|
||||||
|
const addRule = () => {
|
||||||
|
setRules((r) => [...r, { from: "", to: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRule = (idx: number) => {
|
||||||
|
setRules((r) => r.filter((_, i) => i !== idx));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRule = (idx: number, field: "from" | "to", value: string) => {
|
||||||
|
setRules((r) => r.map((rule, i) => (i === idx ? { ...rule, [field]: value } : rule)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(serializeReplacements(rules));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={t("admin.text_replacements_title")}
|
||||||
|
description={t("admin.text_replacements_help")}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" onClick={handleSave} loading={saving} disabled={!dirty}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rules.length === 0 && (
|
||||||
|
<p className="text-xs text-fg-muted">{t("admin.text_replacements_empty")}</p>
|
||||||
|
)}
|
||||||
|
{rules.map((rule, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder={t("admin.text_replacements_from")}
|
||||||
|
value={rule.from}
|
||||||
|
onChange={(e) => updateRule(idx, "from", e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-fg-muted">→</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder={t("admin.text_replacements_to")}
|
||||||
|
value={rule.to}
|
||||||
|
onChange={(e) => updateRule(idx, "to", e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removeRule(idx)}
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={addRule}>
|
||||||
|
+ {t("admin.text_replacements_add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ export interface PhaseProgressProps {
|
|||||||
/** Phases that have started (key) with display names. */
|
/** Phases that have started (key) with display names. */
|
||||||
phases: Array<{ phase: string; name?: string; done?: boolean }>;
|
phases: Array<{ phase: string; name?: string; done?: boolean }>;
|
||||||
currentPhase?: string;
|
currentPhase?: string;
|
||||||
step?: number;
|
/** Step index. Normally numeric, but the world builder also emits
|
||||||
|
* string stage identifiers like "skipping_schema" when resuming —
|
||||||
|
* non-numeric values are ignored for the "Step X of Y" display. */
|
||||||
|
step?: number | string;
|
||||||
totalSteps?: number;
|
totalSteps?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -201,8 +201,11 @@ export function IntroSceneGenerator({ world, onWorldUpdated }: IntroSceneGenerat
|
|||||||
void start();
|
void start();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hide the generator once the world is ready.
|
// Hide the generator once the world is ready AND has an intro scene.
|
||||||
if (world.status === "ready") return null;
|
// We keep showing it for "ready" worlds that somehow have no intro_scene
|
||||||
|
// (e.g. legacy data, or intro generation completed but the scene was
|
||||||
|
// never persisted) so the user can regenerate.
|
||||||
|
if (world.status === "ready" && world.intro_scene) return null;
|
||||||
|
|
||||||
const busy = state.phase === "starting" || state.phase === "streaming";
|
const busy = state.phase === "starting" || state.phase === "streaming";
|
||||||
|
|
||||||
|
|||||||
@@ -21,15 +21,23 @@ import { SseStatus } from "@/components/sessions/SseStatus";
|
|||||||
|
|
||||||
type Mode = "preset" | "form";
|
type Mode = "preset" | "form";
|
||||||
|
|
||||||
|
/** Kind of builder log entry — drives the color used to render it. */
|
||||||
|
type BuilderLogKind = "info" | "skip" | "error" | "warn";
|
||||||
|
|
||||||
|
interface BuilderLogEntry {
|
||||||
|
text: string;
|
||||||
|
kind: BuilderLogKind;
|
||||||
|
}
|
||||||
|
|
||||||
interface BuilderState {
|
interface BuilderState {
|
||||||
phase: "form" | "building" | "done" | "error";
|
phase: "form" | "building" | "done" | "error";
|
||||||
currentPhase?: string;
|
currentPhase?: string;
|
||||||
phases: Array<{ phase: string; name?: string; done: boolean }>;
|
phases: Array<{ phase: string; name?: string; done: boolean }>;
|
||||||
step?: number;
|
step?: number | string;
|
||||||
totalSteps?: number;
|
totalSteps?: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
introScene: string;
|
introScene: string;
|
||||||
logs: string[];
|
logs: BuilderLogEntry[];
|
||||||
sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
|
sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +139,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
...s,
|
...s,
|
||||||
phase: "error",
|
phase: "error",
|
||||||
sseStatus: "error",
|
sseStatus: "error",
|
||||||
logs: [...s.logs, `[error] ${d?.message || "Stream error"}`],
|
logs: [...s.logs, { text: `[error] ${d?.message || "Stream error"}`, kind: "error" as const }],
|
||||||
}));
|
}));
|
||||||
pushToast("error", d?.message || t("builder.build_failed"));
|
pushToast("error", d?.message || t("builder.build_failed"));
|
||||||
controllerRef.current?.close();
|
controllerRef.current?.close();
|
||||||
@@ -139,16 +147,29 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
}
|
}
|
||||||
case "warning": {
|
case "warning": {
|
||||||
const d = event.data as { message?: string };
|
const d = event.data as { message?: string };
|
||||||
setState((s) => ({ ...s, logs: [...s.logs, `[warn] ${d?.message || ""}`] }));
|
setState((s) => ({ ...s, logs: [...s.logs, { text: `[warn] ${d?.message || ""}`, kind: "warn" as const }] }));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "step": {
|
case "step": {
|
||||||
const d = event.data as { step: number; message: string };
|
// The `step` field is normally a numeric index, but the builder
|
||||||
|
// also emits string stage identifiers like "skipping_schema" when
|
||||||
|
// resuming a partially-built world (those stages already exist
|
||||||
|
// and are skipped). We render those in a muted blue so they're
|
||||||
|
// visually distinct from "real" progress steps.
|
||||||
|
const d = event.data as { step?: number | string; message?: string };
|
||||||
|
const stepVal = d.step;
|
||||||
|
const isSkip = typeof stepVal === "string" && stepVal.startsWith("skipping_");
|
||||||
setState((s) => ({
|
setState((s) => ({
|
||||||
...s,
|
...s,
|
||||||
step: d.step,
|
step: stepVal,
|
||||||
message: d.message,
|
message: d.message,
|
||||||
logs: [...s.logs, `[${d.step}] ${d.message}`],
|
logs: [
|
||||||
|
...s.logs,
|
||||||
|
{
|
||||||
|
text: `[${stepVal ?? "?"}] ${d.message || ""}`,
|
||||||
|
kind: isSkip ? ("skip" as const) : ("info" as const),
|
||||||
|
},
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -181,13 +202,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "world_schema_generated":
|
case "world_schema_generated":
|
||||||
setState((s) => ({ ...s, logs: [...s.logs, t("builder.schema_generated")] }));
|
setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.schema_generated"), kind: "info" as const }] }));
|
||||||
break;
|
break;
|
||||||
case "environment_generated":
|
case "environment_generated":
|
||||||
setState((s) => ({ ...s, logs: [...s.logs, t("builder.environment_generated")] }));
|
setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.environment_generated"), kind: "info" as const }] }));
|
||||||
break;
|
break;
|
||||||
case "entities_generated":
|
case "entities_generated":
|
||||||
setState((s) => ({ ...s, logs: [...s.logs, t("builder.entities_generated")] }));
|
setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.entities_generated"), kind: "info" as const }] }));
|
||||||
break;
|
break;
|
||||||
case "intro_scene_chunk": {
|
case "intro_scene_chunk": {
|
||||||
const d = event.data as { text: string };
|
const d = event.data as { text: string };
|
||||||
@@ -464,9 +485,24 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
|||||||
{state.logs.length > 0 && (
|
{state.logs.length > 0 && (
|
||||||
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2">
|
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2">
|
||||||
<summary className="cursor-pointer text-xs text-fg-muted">Logs ({state.logs.length})</summary>
|
<summary className="cursor-pointer text-xs text-fg-muted">Logs ({state.logs.length})</summary>
|
||||||
<pre className="mt-2 max-h-48 overflow-auto text-[10px] text-fg-dim">
|
<div className="mt-2 max-h-48 overflow-auto font-mono text-[10px] leading-relaxed">
|
||||||
{state.logs.join("\n")}
|
{state.logs.map((entry, i) => (
|
||||||
</pre>
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"whitespace-pre-wrap break-words",
|
||||||
|
entry.kind === "error" && "text-err",
|
||||||
|
entry.kind === "warn" && "text-warn",
|
||||||
|
// Skipped stages (resumable builder) get a muted blue
|
||||||
|
// so they're visually distinct from real progress.
|
||||||
|
entry.kind === "skip" && "text-sky-500 dark:text-sky-400",
|
||||||
|
entry.kind === "info" && "text-fg-dim",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
{state.phase === "done" && (
|
{state.phase === "done" && (
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
|
|||||||
const isArchived = world.status === "archived";
|
const isArchived = world.status === "archived";
|
||||||
const isDraft = world.status === "draft";
|
const isDraft = world.status === "draft";
|
||||||
const isAdmin = !!user?.is_admin;
|
const isAdmin = !!user?.is_admin;
|
||||||
|
// Prefer the human-readable time string ("Day 1, 08:00"); fall back to the
|
||||||
|
// raw `current_time` value when the backend doesn't provide the human form.
|
||||||
|
const displayedTime = world.current_time_human || world.current_time;
|
||||||
|
|
||||||
const handleRestore = async () => {
|
const handleRestore = async () => {
|
||||||
setRestoring(true);
|
setRestoring(true);
|
||||||
@@ -103,7 +106,7 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-fg-dim">{t("worlds.current_time")}</dt>
|
<dt className="text-fg-dim">{t("worlds.current_time")}</dt>
|
||||||
<dd className="text-fg truncate">{world.current_time || "—"}</dd>
|
<dd className="text-fg truncate">{displayedTime || "—"}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<dt className="text-fg-dim">{t("worlds.last_played")}</dt>
|
<dt className="text-fg-dim">{t("worlds.last_played")}</dt>
|
||||||
|
|||||||
@@ -66,7 +66,14 @@
|
|||||||
"register_failed": "Registration failed",
|
"register_failed": "Registration failed",
|
||||||
"session_expired": "Session expired, please sign in again.",
|
"session_expired": "Session expired, please sign in again.",
|
||||||
"username_hint": "Letters, numbers, and underscores only. No @ or other special characters.",
|
"username_hint": "Letters, numbers, and underscores only. No @ or other special characters.",
|
||||||
"username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed."
|
"username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed.",
|
||||||
|
"recover_title": "Recover admin access",
|
||||||
|
"recover_help": "Disaster-recovery: creates a new admin account using the setup token. Use this when all admins have lost access.",
|
||||||
|
"recover_token_hint": "The setup token from the server config (admin.setup_token).",
|
||||||
|
"recover_button": "Create admin",
|
||||||
|
"recover_success": "Admin created. You can now log in.",
|
||||||
|
"recover_failed": "Recovery failed",
|
||||||
|
"recover_link": "Lost admin access? Recover here"
|
||||||
},
|
},
|
||||||
"worlds": {
|
"worlds": {
|
||||||
"title": "Your Worlds",
|
"title": "Your Worlds",
|
||||||
@@ -232,6 +239,8 @@
|
|||||||
"logs_filter_apply": "Apply filters",
|
"logs_filter_apply": "Apply filters",
|
||||||
"logs_stage": "Stage",
|
"logs_stage": "Stage",
|
||||||
"logs_status": "Status",
|
"logs_status": "Status",
|
||||||
|
"logs_world": "World",
|
||||||
|
"logs_filtered": "filtered",
|
||||||
"logs_latency": "Latency",
|
"logs_latency": "Latency",
|
||||||
"logs_tokens": "Tokens",
|
"logs_tokens": "Tokens",
|
||||||
"logs_created": "Created",
|
"logs_created": "Created",
|
||||||
@@ -282,7 +291,17 @@
|
|||||||
"tool_calls_detected": "Tool calls detected",
|
"tool_calls_detected": "Tool calls detected",
|
||||||
"no_tool_calls_warning_title": "No tool calls returned",
|
"no_tool_calls_warning_title": "No tool calls returned",
|
||||||
"no_tool_calls_warning": "Model did not return tool calls. This may mean the model doesn't support function calling, or uses a non-standard format.",
|
"no_tool_calls_warning": "Model did not return tool calls. This may mean the model doesn't support function calling, or uses a non-standard format.",
|
||||||
"raw_response": "Raw LLM response"
|
"raw_response": "Raw LLM response",
|
||||||
|
"fetch_models": "Fetch models",
|
||||||
|
"fetched_models": "Available models",
|
||||||
|
"pick_model": "Pick a model…",
|
||||||
|
"fetch_models_failed": "Could not fetch model list. Enter the model name manually.",
|
||||||
|
"text_replacements_title": "Text Replacements",
|
||||||
|
"text_replacements_help": "These replacements are applied to all LLM scene text output. Use empty 'To' to remove a word/phrase entirely.",
|
||||||
|
"text_replacements_empty": "No replacement rules yet. Click 'Add rule' to create one.",
|
||||||
|
"text_replacements_from": "From",
|
||||||
|
"text_replacements_to": "To",
|
||||||
|
"text_replacements_add": "Add rule"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Something went wrong.",
|
"generic": "Something went wrong.",
|
||||||
|
|||||||
@@ -66,7 +66,14 @@
|
|||||||
"register_failed": "Не удалось зарегистрироваться",
|
"register_failed": "Не удалось зарегистрироваться",
|
||||||
"session_expired": "Сессия истекла, пожалуйста, войдите снова.",
|
"session_expired": "Сессия истекла, пожалуйста, войдите снова.",
|
||||||
"username_hint": "Только буквы, цифры и подчёркивание. Без @ и других спецсимволов.",
|
"username_hint": "Только буквы, цифры и подчёркивание. Без @ и других спецсимволов.",
|
||||||
"username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются."
|
"username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются.",
|
||||||
|
"recover_title": "Восстановление доступа администратора",
|
||||||
|
"recover_help": "Аварийное восстановление: создаёт новый аккаунт администратора через установочный токен. Используйте, когда все администраторы потеряли доступ.",
|
||||||
|
"recover_token_hint": "Установочный токен из конфигурации сервера (admin.setup_token).",
|
||||||
|
"recover_button": "Создать администратора",
|
||||||
|
"recover_success": "Администратор создан. Теперь можно войти.",
|
||||||
|
"recover_failed": "Не удалось восстановить доступ",
|
||||||
|
"recover_link": "Потеряли доступ администратора? Восстановить здесь"
|
||||||
},
|
},
|
||||||
"worlds": {
|
"worlds": {
|
||||||
"title": "Ваши миры",
|
"title": "Ваши миры",
|
||||||
@@ -232,6 +239,8 @@
|
|||||||
"logs_filter_apply": "Применить фильтры",
|
"logs_filter_apply": "Применить фильтры",
|
||||||
"logs_stage": "Стадия",
|
"logs_stage": "Стадия",
|
||||||
"logs_status": "Статус",
|
"logs_status": "Статус",
|
||||||
|
"logs_world": "Мир",
|
||||||
|
"logs_filtered": "фильтр",
|
||||||
"logs_latency": "Задержка",
|
"logs_latency": "Задержка",
|
||||||
"logs_tokens": "Токены",
|
"logs_tokens": "Токены",
|
||||||
"logs_created": "Создано",
|
"logs_created": "Создано",
|
||||||
@@ -282,7 +291,17 @@
|
|||||||
"tool_calls_detected": "Обнаружены вызовы инструментов",
|
"tool_calls_detected": "Обнаружены вызовы инструментов",
|
||||||
"no_tool_calls_warning_title": "Вызовы инструментов не возвращены",
|
"no_tool_calls_warning_title": "Вызовы инструментов не возвращены",
|
||||||
"no_tool_calls_warning": "Модель не вернула вызовы инструментов. Это может означать, что модель не поддерживает function calling или использует нестандартный формат.",
|
"no_tool_calls_warning": "Модель не вернула вызовы инструментов. Это может означать, что модель не поддерживает function calling или использует нестандартный формат.",
|
||||||
"raw_response": "Полный ответ модели"
|
"raw_response": "Полный ответ модели",
|
||||||
|
"fetch_models": "Получить модели",
|
||||||
|
"fetched_models": "Доступные модели",
|
||||||
|
"pick_model": "Выберите модель…",
|
||||||
|
"fetch_models_failed": "Не удалось получить список моделей. Введите имя модели вручную.",
|
||||||
|
"text_replacements_title": "Замены текста",
|
||||||
|
"text_replacements_help": "Эти замены применяются ко всему выводу LLM-сцены. Пустое 'На' полностью удаляет слово/фразу.",
|
||||||
|
"text_replacements_empty": "Правил замены пока нет. Нажмите «Добавить правило», чтобы создать.",
|
||||||
|
"text_replacements_from": "С",
|
||||||
|
"text_replacements_to": "На",
|
||||||
|
"text_replacements_add": "Добавить правило"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Что-то пошло не так.",
|
"generic": "Что-то пошло не так.",
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export const SessionsApi = {
|
|||||||
* lives in the sessions API surface for grouping.)
|
* lives in the sessions API surface for grouping.)
|
||||||
*/
|
*/
|
||||||
generateIntro: (worldId: string) =>
|
generateIntro: (worldId: string) =>
|
||||||
request<GenerateIntroResponse>(`/worlds/${worldId}/generate-intro`, { method: "POST" }),
|
request<GenerateIntroResponse>(`/sessions/worlds/${worldId}/generate-intro`, { method: "POST" }),
|
||||||
|
|
||||||
// SSE stream URLs (used by SSE client)
|
// SSE stream URLs (used by SSE client)
|
||||||
iterateStreamUrl: (worldId: string, stepId: string) =>
|
iterateStreamUrl: (worldId: string, stepId: string) =>
|
||||||
@@ -387,7 +387,58 @@ export type LlmLogsQuery = {
|
|||||||
per_page?: number;
|
per_page?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Body for POST /api/admin/recover (NO AUTH). */
|
||||||
|
export interface AdminRecoverPayload {
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from POST /api/admin/recover. */
|
||||||
|
export interface AdminRecoverResponse {
|
||||||
|
ok: boolean;
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from POST /api/admin/llm/models. */
|
||||||
|
export interface LlmModelsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
models?: string[];
|
||||||
|
count?: number;
|
||||||
|
error?: { code: string; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
export const AdminApi = {
|
export const AdminApi = {
|
||||||
|
/**
|
||||||
|
* Disaster-recovery admin creation. NO AUTH required — uses a setup token.
|
||||||
|
* Use when all admins have lost access. The route is mounted publicly by
|
||||||
|
* the backend.
|
||||||
|
*/
|
||||||
|
recover: (body: AdminRecoverPayload) =>
|
||||||
|
request<AdminRecoverResponse>("/admin/recover", { method: "POST", body }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the list of available models from the configured LLM provider.
|
||||||
|
* Pass the api_url / api_key currently entered in the settings form so the
|
||||||
|
* backend can probe the provider directly (it does NOT read saved settings
|
||||||
|
* for this call — the user may have typed but not yet saved).
|
||||||
|
*/
|
||||||
|
listLlmModels: (apiUrl?: string, apiKey?: string) =>
|
||||||
|
request<LlmModelsResponse>(
|
||||||
|
"/admin/llm/models",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
query: {
|
||||||
|
api_url: apiUrl || undefined,
|
||||||
|
api_key: apiKey || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
settings: () => request<AdminSettingsResponse>("/admin/settings"),
|
settings: () => request<AdminSettingsResponse>("/admin/settings"),
|
||||||
updateSettings: (settings: Record<string, string>) =>
|
updateSettings: (settings: Record<string, string>) =>
|
||||||
request<{ updated: Record<string, string> }>("/admin/settings", { method: "PATCH", body: { settings } }),
|
request<{ updated: Record<string, string> }>("/admin/settings", { method: "PATCH", body: { settings } }),
|
||||||
|
|||||||
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")}
|
{t("auth.no_account")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-center text-xs text-fg-dim">
|
||||||
|
<Link to="/recover" className="text-fg-muted hover:text-accent hover:underline">
|
||||||
|
{t("auth.recover_link")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -206,7 +206,13 @@ export function PlayPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold text-fg">{world.name}</h2>
|
<h2 className="text-base font-semibold text-fg">{world.name}</h2>
|
||||||
<p className="text-xs text-fg-muted">
|
<p className="text-xs text-fg-muted">
|
||||||
{world.current_time ? `${t("worlds.current_time")}: ${world.current_time}` : ""}
|
{(() => {
|
||||||
|
// Prefer the human-readable form; fall back to the raw
|
||||||
|
// current_time string when the backend doesn't supply it
|
||||||
|
// (e.g. older session state cached locally).
|
||||||
|
const time = world.current_time_human || world.current_time;
|
||||||
|
return time ? `${t("worlds.current_time")}: ${time}` : "";
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<SseStatus status={sseStatus} />
|
<SseStatus status={sseStatus} />
|
||||||
|
|||||||
@@ -66,13 +66,27 @@ export function WorldEditPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDraft = world.status === "draft";
|
const isDraft = world.status === "draft";
|
||||||
|
// Show the IntroSceneGenerator whenever the world is still a draft (the
|
||||||
|
// intro scene is the thing that flips a draft → ready) OR whenever the
|
||||||
|
// intro scene is missing for any reason (e.g. legacy data, interrupted
|
||||||
|
// build). After successful intro generation, `refreshWorld` updates the
|
||||||
|
// world to status "ready" with intro_scene set, and IntroSceneGenerator
|
||||||
|
// then returns null on its next render.
|
||||||
|
const showIntroGenerator = world.status === "draft" || !world.intro_scene;
|
||||||
|
// Prefer the human-readable time string; fall back to the raw value.
|
||||||
|
const displayedTime = world.current_time_human || world.current_time;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1>
|
<h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1>
|
||||||
<p className="text-sm text-fg-muted">{world.name}</p>
|
<p className="text-sm text-fg-muted">
|
||||||
|
{world.name}
|
||||||
|
{displayedTime && (
|
||||||
|
<span className="ml-2 text-fg-dim">· {t("worlds.current_time")}: {displayedTime}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -85,7 +99,7 @@ export function WorldEditPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{isDraft && (
|
{showIntroGenerator && (
|
||||||
<IntroSceneGenerator world={world} onWorldUpdated={() => void refreshWorld()} />
|
<IntroSceneGenerator world={world} onWorldUpdated={() => void refreshWorld()} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export interface WorldListItem {
|
|||||||
status: WorldStatus;
|
status: WorldStatus;
|
||||||
last_played_at: string | null;
|
last_played_at: string | null;
|
||||||
current_time: string | null;
|
current_time: string | null;
|
||||||
|
/** Human-readable form of `current_time` (e.g. "Day 1, 08:00"). May be
|
||||||
|
* absent on older backend versions — fall back to `current_time`. */
|
||||||
|
current_time_human?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
preview_player_name: string | null;
|
preview_player_name: string | null;
|
||||||
}
|
}
|
||||||
@@ -125,6 +128,10 @@ export interface World {
|
|||||||
environment: Environment;
|
environment: Environment;
|
||||||
plot_rails: PlotRail[];
|
plot_rails: PlotRail[];
|
||||||
current_time: string | null;
|
current_time: string | null;
|
||||||
|
/** Human-readable form of `current_time` (e.g. "Day 1, 08:00").
|
||||||
|
* Returned by GET /api/sessions/worlds/{id}/state on the world object.
|
||||||
|
* May be absent on older backend versions — fall back to `current_time`. */
|
||||||
|
current_time_human?: string | null;
|
||||||
status: WorldStatus;
|
status: WorldStatus;
|
||||||
intro_scene: string | null;
|
intro_scene: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -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