From e98559a587943bcda76d1d2c9fde14601862a9a6 Mon Sep 17 00:00:00 2001 From: Mikan <72257910+Mikan-DS@users.noreply.github.com> Date: Sun, 21 Jun 2026 06:12:28 +0300 Subject: [PATCH] fix --- CHANGELOG.md | 60 +++ app/api/admin.py | 126 +++++- app/api/sessions.py | 6 +- app/api/worlds.py | 2 + app/core/settings_service.py | 25 ++ app/core/time_utils.py | 35 ++ app/engine/game_master.py | 3 + app/engine/tools/game.py | 8 +- app/engine/tools/schema_tools.py | 8 +- app/engine/world_builder.py | 363 +++++++++++------- app/engine/world_editor.py | 20 +- app/prompts/stages/world_builder_env.py | 43 ++- app/prompts/stages/world_builder_schema.py | 70 ++-- frontend/src/App.tsx | 11 + .../src/components/admin/LlmLogsTable.tsx | 17 + .../src/components/admin/SettingsPanel.tsx | 357 +++++++++++++++-- .../src/components/sessions/PhaseProgress.tsx | 5 +- .../components/worlds/IntroSceneGenerator.tsx | 7 +- .../src/components/worlds/WorldBuilder.tsx | 62 ++- frontend/src/components/worlds/WorldCard.tsx | 5 +- frontend/src/i18n/en.json | 23 +- frontend/src/i18n/ru.json | 23 +- frontend/src/lib/api.ts | 53 ++- frontend/src/pages/AdminRecoverPage.tsx | 143 +++++++ frontend/src/pages/LoginPage.tsx | 5 + frontend/src/pages/PlayPage.tsx | 8 +- frontend/src/pages/WorldEditPage.tsx | 18 +- frontend/src/types/index.ts | 7 + frontend/tsconfig.tsbuildinfo | 2 +- 29 files changed, 1248 insertions(+), 267 deletions(-) create mode 100644 frontend/src/pages/AdminRecoverPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index a51cb02..4f67e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,66 @@ All notable changes to AI-RPG are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] — 2026-06-21 + +Major release: world builder rewritten to use tools (instead of JSON), resumable builder flow, admin recovery, LLM model list, text replacements, human-readable time. + +### Backend — Critical: World Builder rewritten to use tools + +- **`world_builder_schema` stage**: previously asked the LLM to output a JSON object with schemas, which frequently failed validation. Now the LLM calls `schema_add_type` tool for each entity type. Added `world_builder_schema` and `world_builder_env` to the `stages` set of all relevant tools (schema_add_type, env_update, entity_create, submit_plan, etc.). +- **`world_builder_env` stage**: previously asked for JSON. Now the LLM calls `env_update` tool to set player, current_location, and plot_rails. +- **Resumable builder**: each stage checks if the world already has the needed data and skips if so. If schemas exist → skip schema generation. If environment has current_location → skip env generation. If entities exist → skip entity generation. If intro_scene exists → skip intro generation. This allows re-running the builder after a failure at any stage without redoing earlier stages. +- **Validation warnings instead of failures**: if `validate_world` finds issues after env generation, the builder emits a `warning` SSE event but continues (instead of failing). The world may still be usable. +- **Better tool-loop prompt**: the user message now says "Use the available tools to accomplish the task. When done, call {terminal_tool}." to encourage tool use. +- **Updated prompts**: `world_builder_schema` and `world_builder_env` prompts now describe the tools to use and give examples of tool arguments. + +### Backend — Critical: World Editor fixes + +- **No extra LLM call after propose_changes**: after the user accepts or rejects proposed changes, the editor loop now breaks immediately. Previously it made another LLM call (which returned empty text), wasting API requests. +- **`'str' object has no attribute 'get'` fix**: already in v1.2.0, but now also handles cases where `function` is not a dict. + +### Backend — New: Admin recovery + +- **`POST /api/admin/recover`** (NO AUTH required) — creates a new admin user using the `admin.setup_token` (printed on every backend startup). Body: `{token, email, username, password}`. For disaster recovery when all existing admins lost access. Returns 403 `invalid_admin_token` if the token doesn't match. + +### Backend — New: LLM model list + +- **`POST /api/admin/llm/models`** (admin) — fetches the list of available models from an OpenAI-compatible API (`GET {api_url}/models`). Returns `{ok: true, models: [...], count: N}` or `{ok: false, error: {...}, models: []}`. Uses the same `_resolve` helper as test endpoints (ignores masked api_key values). + +### Backend — New: Text replacements + +- **New setting `llm.text_replacements`**: a JSON array of `{from: string, to: string}` pairs. Applied to all LLM scene_text output (both orchestrator Phase 2 and intro_scene). Use empty `to` to remove a word/phrase entirely. +- **`apply_text_replacements(session, text)`** helper in `settings_service.py`. +- Applied in `game_master.py` (Phase 2 writer) and `world_builder.py` (intro scene). + +### Backend — New: Human-readable time + +- **`format_time_human(time_str, language)`** in `time_utils.py` — converts `"day_1_hour_8"` → `"Day 1, 08:00"` (en) or `"День 1, 08:00"` (ru). Supports years, days, hours, minutes. +- **`GET /api/worlds`** now returns `current_time_human` alongside `current_time`. +- **`GET /api/sessions/worlds/{id}/state`** now returns `current_time_human` and `status` in the world object. + +### Backend — New: world_id in LLM logs + +- **`GET /api/admin/llm-logs`** now includes `world_id` (string UUID or null) on each log item. Useful for the admin UI to show which world a log belongs to, even when not filtering by world_id. + +### Backend — Route fix + +- **404 on generate-intro**: the frontend was calling `/api/worlds/{id}/generate-intro` but the route is at `/api/sessions/worlds/{id}/generate-intro`. Fixed the frontend API helper to use the correct path. + +### Frontend — 14 files changed, 1 new + +- **Admin recovery page**: new `/recover` route (public, no auth). Form with token/email/username/password. Link from LoginPage: "Lost admin access? Recover here". +- **LLM model list dropdown**: "Fetch models" button next to the model input in SettingsPanel. Fetches from `POST /api/admin/llm/models`. Shows a ` onChange(e.target.value)} + placeholder="gpt-4o-mini" + /> + + + {hint &&

{hint}

} + {models && models.length > 0 && ( +
+ + +
+ )} + {fetchError && ( +

+ {t("admin.fetch_models_failed")} +

+ )} + + ); +} + +// ============================================================================ +// 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(() => 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 ( + + {t("common.save")} + + } + > +
+ {rules.length === 0 && ( +

{t("admin.text_replacements_empty")}

+ )} + {rules.map((rule, idx) => ( +
+ updateRule(idx, "from", e.target.value)} + autoComplete="off" + /> + + updateRule(idx, "to", e.target.value)} + autoComplete="off" + /> + +
+ ))} + +
+
+ ); +} diff --git a/frontend/src/components/sessions/PhaseProgress.tsx b/frontend/src/components/sessions/PhaseProgress.tsx index e889ba2..75fe6d0 100644 --- a/frontend/src/components/sessions/PhaseProgress.tsx +++ b/frontend/src/components/sessions/PhaseProgress.tsx @@ -6,7 +6,10 @@ export interface PhaseProgressProps { /** Phases that have started (key) with display names. */ phases: Array<{ phase: string; name?: string; done?: boolean }>; currentPhase?: string; - step?: number; + /** Step index. Normally numeric, but the world builder also emits + * string stage identifiers like "skipping_schema" when resuming — + * non-numeric values are ignored for the "Step X of Y" display. */ + step?: number | string; totalSteps?: number; message?: string; className?: string; diff --git a/frontend/src/components/worlds/IntroSceneGenerator.tsx b/frontend/src/components/worlds/IntroSceneGenerator.tsx index bfde897..cd5f515 100644 --- a/frontend/src/components/worlds/IntroSceneGenerator.tsx +++ b/frontend/src/components/worlds/IntroSceneGenerator.tsx @@ -201,8 +201,11 @@ export function IntroSceneGenerator({ world, onWorldUpdated }: IntroSceneGenerat void start(); }; - // Hide the generator once the world is ready. - if (world.status === "ready") return null; + // Hide the generator once the world is ready AND has an intro scene. + // We keep showing it for "ready" worlds that somehow have no intro_scene + // (e.g. legacy data, or intro generation completed but the scene was + // never persisted) so the user can regenerate. + if (world.status === "ready" && world.intro_scene) return null; const busy = state.phase === "starting" || state.phase === "streaming"; diff --git a/frontend/src/components/worlds/WorldBuilder.tsx b/frontend/src/components/worlds/WorldBuilder.tsx index 491fdae..d8601d4 100644 --- a/frontend/src/components/worlds/WorldBuilder.tsx +++ b/frontend/src/components/worlds/WorldBuilder.tsx @@ -21,15 +21,23 @@ import { SseStatus } from "@/components/sessions/SseStatus"; type Mode = "preset" | "form"; +/** Kind of builder log entry — drives the color used to render it. */ +type BuilderLogKind = "info" | "skip" | "error" | "warn"; + +interface BuilderLogEntry { + text: string; + kind: BuilderLogKind; +} + interface BuilderState { phase: "form" | "building" | "done" | "error"; currentPhase?: string; phases: Array<{ phase: string; name?: string; done: boolean }>; - step?: number; + step?: number | string; totalSteps?: number; message?: string; introScene: string; - logs: string[]; + logs: BuilderLogEntry[]; sseStatus: "idle" | "connecting" | "open" | "error" | "closed"; } @@ -131,7 +139,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) { ...s, phase: "error", sseStatus: "error", - logs: [...s.logs, `[error] ${d?.message || "Stream error"}`], + logs: [...s.logs, { text: `[error] ${d?.message || "Stream error"}`, kind: "error" as const }], })); pushToast("error", d?.message || t("builder.build_failed")); controllerRef.current?.close(); @@ -139,16 +147,29 @@ export function WorldBuilder({ className }: WorldBuilderProps) { } case "warning": { const d = event.data as { message?: string }; - setState((s) => ({ ...s, logs: [...s.logs, `[warn] ${d?.message || ""}`] })); + setState((s) => ({ ...s, logs: [...s.logs, { text: `[warn] ${d?.message || ""}`, kind: "warn" as const }] })); break; } case "step": { - const d = event.data as { step: number; message: string }; + // The `step` field is normally a numeric index, but the builder + // also emits string stage identifiers like "skipping_schema" when + // resuming a partially-built world (those stages already exist + // and are skipped). We render those in a muted blue so they're + // visually distinct from "real" progress steps. + const d = event.data as { step?: number | string; message?: string }; + const stepVal = d.step; + const isSkip = typeof stepVal === "string" && stepVal.startsWith("skipping_"); setState((s) => ({ ...s, - step: d.step, + step: stepVal, message: d.message, - logs: [...s.logs, `[${d.step}] ${d.message}`], + logs: [ + ...s.logs, + { + text: `[${stepVal ?? "?"}] ${d.message || ""}`, + kind: isSkip ? ("skip" as const) : ("info" as const), + }, + ], })); break; } @@ -181,13 +202,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) { break; } case "world_schema_generated": - setState((s) => ({ ...s, logs: [...s.logs, t("builder.schema_generated")] })); + setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.schema_generated"), kind: "info" as const }] })); break; case "environment_generated": - setState((s) => ({ ...s, logs: [...s.logs, t("builder.environment_generated")] })); + setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.environment_generated"), kind: "info" as const }] })); break; case "entities_generated": - setState((s) => ({ ...s, logs: [...s.logs, t("builder.entities_generated")] })); + setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.entities_generated"), kind: "info" as const }] })); break; case "intro_scene_chunk": { const d = event.data as { text: string }; @@ -464,9 +485,24 @@ export function WorldBuilder({ className }: WorldBuilderProps) { {state.logs.length > 0 && (
Logs ({state.logs.length}) -
-                  {state.logs.join("\n")}
-                
+
+ {state.logs.map((entry, i) => ( +
+ {entry.text} +
+ ))} +
)} {state.phase === "done" && ( diff --git a/frontend/src/components/worlds/WorldCard.tsx b/frontend/src/components/worlds/WorldCard.tsx index b238cae..ec5c1e9 100644 --- a/frontend/src/components/worlds/WorldCard.tsx +++ b/frontend/src/components/worlds/WorldCard.tsx @@ -45,6 +45,9 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c const isArchived = world.status === "archived"; const isDraft = world.status === "draft"; const isAdmin = !!user?.is_admin; + // Prefer the human-readable time string ("Day 1, 08:00"); fall back to the + // raw `current_time` value when the backend doesn't provide the human form. + const displayedTime = world.current_time_human || world.current_time; const handleRestore = async () => { setRestoring(true); @@ -103,7 +106,7 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
{t("worlds.current_time")}
-
{world.current_time || "—"}
+
{displayedTime || "—"}
{t("worlds.last_played")}
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 4b8a325..7864c0e 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -66,7 +66,14 @@ "register_failed": "Registration failed", "session_expired": "Session expired, please sign in again.", "username_hint": "Letters, numbers, and underscores only. No @ or other special characters.", - "username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed." + "username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed.", + "recover_title": "Recover admin access", + "recover_help": "Disaster-recovery: creates a new admin account using the setup token. Use this when all admins have lost access.", + "recover_token_hint": "The setup token from the server config (admin.setup_token).", + "recover_button": "Create admin", + "recover_success": "Admin created. You can now log in.", + "recover_failed": "Recovery failed", + "recover_link": "Lost admin access? Recover here" }, "worlds": { "title": "Your Worlds", @@ -232,6 +239,8 @@ "logs_filter_apply": "Apply filters", "logs_stage": "Stage", "logs_status": "Status", + "logs_world": "World", + "logs_filtered": "filtered", "logs_latency": "Latency", "logs_tokens": "Tokens", "logs_created": "Created", @@ -282,7 +291,17 @@ "tool_calls_detected": "Tool calls detected", "no_tool_calls_warning_title": "No tool calls returned", "no_tool_calls_warning": "Model did not return tool calls. This may mean the model doesn't support function calling, or uses a non-standard format.", - "raw_response": "Raw LLM response" + "raw_response": "Raw LLM response", + "fetch_models": "Fetch models", + "fetched_models": "Available models", + "pick_model": "Pick a model…", + "fetch_models_failed": "Could not fetch model list. Enter the model name manually.", + "text_replacements_title": "Text Replacements", + "text_replacements_help": "These replacements are applied to all LLM scene text output. Use empty 'To' to remove a word/phrase entirely.", + "text_replacements_empty": "No replacement rules yet. Click 'Add rule' to create one.", + "text_replacements_from": "From", + "text_replacements_to": "To", + "text_replacements_add": "Add rule" }, "errors": { "generic": "Something went wrong.", diff --git a/frontend/src/i18n/ru.json b/frontend/src/i18n/ru.json index a3bcace..3ede88b 100644 --- a/frontend/src/i18n/ru.json +++ b/frontend/src/i18n/ru.json @@ -66,7 +66,14 @@ "register_failed": "Не удалось зарегистрироваться", "session_expired": "Сессия истекла, пожалуйста, войдите снова.", "username_hint": "Только буквы, цифры и подчёркивание. Без @ и других спецсимволов.", - "username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются." + "username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются.", + "recover_title": "Восстановление доступа администратора", + "recover_help": "Аварийное восстановление: создаёт новый аккаунт администратора через установочный токен. Используйте, когда все администраторы потеряли доступ.", + "recover_token_hint": "Установочный токен из конфигурации сервера (admin.setup_token).", + "recover_button": "Создать администратора", + "recover_success": "Администратор создан. Теперь можно войти.", + "recover_failed": "Не удалось восстановить доступ", + "recover_link": "Потеряли доступ администратора? Восстановить здесь" }, "worlds": { "title": "Ваши миры", @@ -232,6 +239,8 @@ "logs_filter_apply": "Применить фильтры", "logs_stage": "Стадия", "logs_status": "Статус", + "logs_world": "Мир", + "logs_filtered": "фильтр", "logs_latency": "Задержка", "logs_tokens": "Токены", "logs_created": "Создано", @@ -282,7 +291,17 @@ "tool_calls_detected": "Обнаружены вызовы инструментов", "no_tool_calls_warning_title": "Вызовы инструментов не возвращены", "no_tool_calls_warning": "Модель не вернула вызовы инструментов. Это может означать, что модель не поддерживает function calling или использует нестандартный формат.", - "raw_response": "Полный ответ модели" + "raw_response": "Полный ответ модели", + "fetch_models": "Получить модели", + "fetched_models": "Доступные модели", + "pick_model": "Выберите модель…", + "fetch_models_failed": "Не удалось получить список моделей. Введите имя модели вручную.", + "text_replacements_title": "Замены текста", + "text_replacements_help": "Эти замены применяются ко всему выводу LLM-сцены. Пустое 'На' полностью удаляет слово/фразу.", + "text_replacements_empty": "Правил замены пока нет. Нажмите «Добавить правило», чтобы создать.", + "text_replacements_from": "С", + "text_replacements_to": "На", + "text_replacements_add": "Добавить правило" }, "errors": { "generic": "Что-то пошло не так.", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9a41319..c8bc763 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -353,7 +353,7 @@ export const SessionsApi = { * lives in the sessions API surface for grouping.) */ generateIntro: (worldId: string) => - request(`/worlds/${worldId}/generate-intro`, { method: "POST" }), + request(`/sessions/worlds/${worldId}/generate-intro`, { method: "POST" }), // SSE stream URLs (used by SSE client) iterateStreamUrl: (worldId: string, stepId: string) => @@ -387,7 +387,58 @@ export type LlmLogsQuery = { per_page?: number; }; +/** Body for POST /api/admin/recover (NO AUTH). */ +export interface AdminRecoverPayload { + token: string; + email: string; + username: string; + password: string; +} + +/** Response from POST /api/admin/recover. */ +export interface AdminRecoverResponse { + ok: boolean; + id: string; + email: string; + username: string; + is_admin: boolean; +} + +/** Response from POST /api/admin/llm/models. */ +export interface LlmModelsResponse { + ok: boolean; + models?: string[]; + count?: number; + error?: { code: string; message: string }; +} + export const AdminApi = { + /** + * Disaster-recovery admin creation. NO AUTH required — uses a setup token. + * Use when all admins have lost access. The route is mounted publicly by + * the backend. + */ + recover: (body: AdminRecoverPayload) => + request("/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( + "/admin/llm/models", + { + method: "POST", + query: { + api_url: apiUrl || undefined, + api_key: apiKey || undefined, + }, + }, + ), + settings: () => request("/admin/settings"), updateSettings: (settings: Record) => request<{ updated: Record }>("/admin/settings", { method: "PATCH", body: { settings } }), diff --git a/frontend/src/pages/AdminRecoverPage.tsx b/frontend/src/pages/AdminRecoverPage.tsx new file mode 100644 index 0000000..d551d16 --- /dev/null +++ b/frontend/src/pages/AdminRecoverPage.tsx @@ -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>({}); + const [topError, setTopError] = useState(null); + + const validate = (): boolean => { + const next: Record = {}; + 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 ( +
+ +

+ {t("auth.recover_help")} +

+
+ setToken(e.target.value)} + required + error={errors.token} + hint={t("auth.recover_token_hint")} + /> + setEmail(e.target.value)} + autoComplete="email" + required + error={errors.email} + /> + setUsername(e.target.value)} + autoComplete="username" + required + error={errors.username} + hint={t("auth.username_hint")} + /> + setPassword(e.target.value)} + autoComplete="new-password" + required + error={errors.password} + /> + setPasswordConfirm(e.target.value)} + autoComplete="new-password" + required + error={errors.password_confirm} + /> + {topError && ( +

+ {topError} +

+ )} + +

+ + {t("auth.have_account")} + +

+
+
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 5b8c4b8..fad6022 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -62,6 +62,11 @@ export function LoginPage() { {t("auth.no_account")}

+

+ + {t("auth.recover_link")} + +

diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index 04c4a36..4699728 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -206,7 +206,13 @@ export function PlayPage() {

{world.name}

- {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}` : ""; + })()}

diff --git a/frontend/src/pages/WorldEditPage.tsx b/frontend/src/pages/WorldEditPage.tsx index 061f984..206212b 100644 --- a/frontend/src/pages/WorldEditPage.tsx +++ b/frontend/src/pages/WorldEditPage.tsx @@ -66,13 +66,27 @@ export function WorldEditPage() { } const isDraft = world.status === "draft"; + // Show the IntroSceneGenerator whenever the world is still a draft (the + // intro scene is the thing that flips a draft → ready) OR whenever the + // intro scene is missing for any reason (e.g. legacy data, interrupted + // build). After successful intro generation, `refreshWorld` updates the + // world to status "ready" with intro_scene set, and IntroSceneGenerator + // then returns null on its next render. + const showIntroGenerator = world.status === "draft" || !world.intro_scene; + // Prefer the human-readable time string; fall back to the raw value. + const displayedTime = world.current_time_human || world.current_time; return (

{t("editor.title")}

-

{world.name}

+

+ {world.name} + {displayedTime && ( + · {t("worlds.current_time")}: {displayedTime} + )} +

- {isDraft && ( + {showIntroGenerator && ( void refreshWorld()} /> )} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5f96bb8..da1e7a2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -53,6 +53,9 @@ export interface WorldListItem { status: WorldStatus; last_played_at: string | null; current_time: string | null; + /** Human-readable form of `current_time` (e.g. "Day 1, 08:00"). May be + * absent on older backend versions — fall back to `current_time`. */ + current_time_human?: string | null; created_at: string; preview_player_name: string | null; } @@ -125,6 +128,10 @@ export interface World { environment: Environment; plot_rails: PlotRail[]; current_time: string | null; + /** Human-readable form of `current_time` (e.g. "Day 1, 08:00"). + * Returned by GET /api/sessions/worlds/{id}/state on the world object. + * May be absent on older backend versions — fall back to `current_time`. */ + current_time_human?: string | null; status: WorldStatus; intro_scene: string | null; created_at: string; diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index bac256a..98e88a0 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file