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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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