fix
This commit is contained in:
@@ -15,6 +15,7 @@ import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
import { LoginPage } from "@/pages/LoginPage";
|
||||
import { RegisterPage } from "@/pages/RegisterPage";
|
||||
import { AdminRegisterPage } from "@/pages/AdminRegisterPage";
|
||||
import { AdminRecoverPage } from "@/pages/AdminRecoverPage";
|
||||
import { WorldsListPage } from "@/pages/WorldsListPage";
|
||||
import { WorldBuilderPage } from "@/pages/WorldBuilderPage";
|
||||
import { WorldEditPage } from "@/pages/WorldEditPage";
|
||||
@@ -109,6 +110,16 @@ export default function App() {
|
||||
</PublicOnly>
|
||||
}
|
||||
/>
|
||||
{/* Disaster-recovery admin creation — PUBLIC (no auth), not behind
|
||||
ProtectedRoute. Used when all admins have lost access. */}
|
||||
<Route
|
||||
path="/recover"
|
||||
element={
|
||||
<Layout>
|
||||
<AdminRecoverPage />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
|
||||
@@ -92,6 +92,11 @@ export function LlmLogsTable() {
|
||||
}
|
||||
};
|
||||
|
||||
// Short preview of the world_id filter value, for the column header.
|
||||
const worldFilterPreview = appliedFilters.world_id
|
||||
? appliedFilters.world_id.slice(0, 8)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card title={t("admin.tab_logs")}>
|
||||
@@ -149,6 +154,14 @@ export function LlmLogsTable() {
|
||||
<thead>
|
||||
<tr className="border-b border-fg-dim/20 text-left text-xs uppercase text-fg-muted">
|
||||
<th className="p-2">{t("admin.logs_stage")}</th>
|
||||
<th className="p-2">
|
||||
{t("admin.logs_world")}
|
||||
{worldFilterPreview && (
|
||||
<span className="ml-1 normal-case text-fg-dim">
|
||||
({t("admin.logs_filtered")}: {worldFilterPreview})
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="p-2">{t("admin.logs_status")}</th>
|
||||
<th className="p-2">{t("admin.logs_latency")}</th>
|
||||
<th className="p-2">{t("admin.logs_tokens")}</th>
|
||||
@@ -160,6 +173,9 @@ export function LlmLogsTable() {
|
||||
{data.items.map((log) => (
|
||||
<tr key={log.id} className="border-b border-fg-dim/10 hover:bg-bg-soft">
|
||||
<td className="p-2 font-mono text-xs">{log.stage}</td>
|
||||
<td className="p-2 font-mono text-xs text-fg-muted">
|
||||
{log.world_id ? log.world_id.slice(0, 8) : "—"}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={`badge ${statusColor(log.status)}`}>
|
||||
{log.status}
|
||||
@@ -230,6 +246,7 @@ export function LlmLogsTable() {
|
||||
badgeClass={statusColor(detail.status)}
|
||||
/>
|
||||
<Field label={t("admin.model")} value={detail.model || "—"} />
|
||||
<Field label={t("admin.logs_world")} value={detail.world_id || "—"} />
|
||||
<Field
|
||||
label={t("admin.logs_latency")}
|
||||
value={detail.latency_ms != null ? `${detail.latency_ms} ms` : "—"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { AdminApi, ApiError } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { refreshUiSettings } from "@/stores/uiSettingsStore";
|
||||
import type { AdminSettingsResponse } from "@/types";
|
||||
@@ -33,6 +33,11 @@ function groupFor(key: string): GroupDef | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Setting keys rendered with a specialized UI rather than the generic
|
||||
* text/integer/boolean row. They are filtered out of the regular field
|
||||
* list and rendered separately. */
|
||||
const SPECIAL_KEYS = new Set<string>(["llm.model", "llm.text_replacements"]);
|
||||
|
||||
/** Field types — drives which control is rendered. */
|
||||
type FieldType = "integer" | "float" | "boolean" | "provider" | "secret" | "text";
|
||||
|
||||
@@ -143,6 +148,9 @@ export function SettingsPanel() {
|
||||
for (const key of Object.keys(data.settings)) {
|
||||
const g = groupFor(key);
|
||||
if (!g) continue;
|
||||
// Special-rendered keys are filtered out of the regular list — they
|
||||
// get their own dedicated UI (model fetcher, text replacements).
|
||||
if (SPECIAL_KEYS.has(key)) continue;
|
||||
(out[g.id] ||= []).push({ key, description: data.descriptions?.[key] });
|
||||
}
|
||||
// Sort each group's keys alphabetically for stable display.
|
||||
@@ -199,6 +207,30 @@ export function SettingsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save a single setting key (used by the Text Replacements card, which
|
||||
* saves only `llm.text_replacements`). No casting is applied — the value
|
||||
* is stored as-is (a JSON string).
|
||||
*/
|
||||
const handleSaveKey = async (key: string, value: string) => {
|
||||
if (!data) return;
|
||||
const before = data.settings[key] ?? "";
|
||||
if (before === value) {
|
||||
pushToast("info", "No changes to save.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await AdminApi.updateSettings({ [key]: value });
|
||||
const nextSettings = { ...data.settings, [key]: res.updated[key] };
|
||||
setData({ ...data, settings: nextSettings });
|
||||
setDraft((d) => ({ ...d, [key]: res.updated[key] }));
|
||||
pushToast("success", t("admin.settings_saved"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to save settings";
|
||||
pushToast("error", msg);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@@ -236,18 +268,30 @@ export function SettingsPanel() {
|
||||
</div>
|
||||
{GROUPS.map((g) => {
|
||||
const entries = grouped[g.id];
|
||||
if (!entries || entries.length === 0) return null;
|
||||
// LLM group always renders even if api_url/api_key/etc. are missing
|
||||
// from the backend response — the specialized sub-cards (model
|
||||
// fetcher, text replacements) live here. For other groups, skip
|
||||
// when empty.
|
||||
if ((!entries || entries.length === 0) && g.id !== "llm") return null;
|
||||
return (
|
||||
<SettingsGroupCard
|
||||
key={g.id}
|
||||
title={t(g.labelKey)}
|
||||
entries={entries}
|
||||
draft={draft}
|
||||
onChange={(key, value) =>
|
||||
setDraft((d) => ({ ...d, [key]: value }))
|
||||
}
|
||||
onSave={() => void handleSaveGroup(g.id)}
|
||||
/>
|
||||
<Fragment key={g.id}>
|
||||
<SettingsGroupCard
|
||||
groupId={g.id}
|
||||
title={t(g.labelKey)}
|
||||
entries={entries || []}
|
||||
draft={draft}
|
||||
onChange={(key, value) =>
|
||||
setDraft((d) => ({ ...d, [key]: value }))
|
||||
}
|
||||
onSave={() => void handleSaveGroup(g.id)}
|
||||
/>
|
||||
{g.id === "llm" && (
|
||||
<TextReplacementsCard
|
||||
rawValue={draft["llm.text_replacements"] ?? ""}
|
||||
onSave={(v) => void handleSaveKey("llm.text_replacements", v)}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -255,6 +299,7 @@ export function SettingsPanel() {
|
||||
}
|
||||
|
||||
interface SettingsGroupCardProps {
|
||||
groupId: string;
|
||||
title: string;
|
||||
entries: Array<{ key: string; description?: string }>;
|
||||
draft: Record<string, string>;
|
||||
@@ -262,7 +307,7 @@ interface SettingsGroupCardProps {
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function SettingsGroupCard({ title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
|
||||
function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const handleSave = async () => {
|
||||
@@ -273,6 +318,17 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
// The "llm.model" key is rendered as a full-width specialized field with
|
||||
// a "Fetch models" button — pull it out of the regular grid flow when
|
||||
// present so it can span both columns.
|
||||
const modelEntry = groupId === "llm"
|
||||
? (entries.find((e) => e.key === "llm.model") ?? (draft["llm.model"] !== undefined ? { key: "llm.model" } : null))
|
||||
: null;
|
||||
const regularEntries = entries.filter((e) => e.key !== "llm.model");
|
||||
// Always render the LLM group even if only `llm.model` is present, since
|
||||
// the model field is special.
|
||||
const showGrid = regularEntries.length > 0;
|
||||
void groupId; // groupId currently used only for the model-entry lookup above
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
@@ -282,16 +338,29 @@ function SettingsGroupCard({ title, entries, draft, onChange, onSave }: Settings
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{entries.map(({ key, description }) => (
|
||||
<SettingField
|
||||
key={key}
|
||||
settingKey={key}
|
||||
description={description}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
<div className="space-y-3">
|
||||
{showGrid && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{regularEntries.map(({ key, description }) => (
|
||||
<SettingField
|
||||
key={key}
|
||||
settingKey={key}
|
||||
description={description}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{modelEntry && (
|
||||
<LlmModelField
|
||||
value={draft["llm.model"] ?? ""}
|
||||
onChange={(v) => onChange("llm.model", v)}
|
||||
apiUrl={draft["llm.api_url"] ?? ""}
|
||||
apiKey={draft["llm.api_key"] ?? ""}
|
||||
description={modelEntry.description}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -411,3 +480,245 @@ function SettingField({ settingKey, description, value, onChange }: SettingField
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LLM model field — text input + "Fetch models" button + dropdown.
|
||||
// ============================================================================
|
||||
|
||||
interface LlmModelFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized renderer for the `llm.model` setting. Shows a text input
|
||||
* (where the user can type any model name) plus a "Fetch models" button
|
||||
* that probes the configured provider and, on success, renders a dropdown
|
||||
* of available models below the input. Selecting from the dropdown fills
|
||||
* the text input. The fetched list is cached in component state — it is
|
||||
* only re-fetched when the button is clicked.
|
||||
*/
|
||||
function LlmModelField({ value, onChange, apiUrl, apiKey, description }: LlmModelFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [models, setModels] = useState<string[] | null>(null);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
const hint = description;
|
||||
|
||||
const handleFetch = async () => {
|
||||
setFetching(true);
|
||||
setFetchError(false);
|
||||
try {
|
||||
const res = await AdminApi.listLlmModels(apiUrl || undefined, apiKey || undefined);
|
||||
if (res.ok && Array.isArray(res.models)) {
|
||||
setModels(res.models);
|
||||
} else {
|
||||
setModels([]);
|
||||
setFetchError(true);
|
||||
}
|
||||
} catch (err) {
|
||||
// ApiError carries a message from the backend (e.g. provider down,
|
||||
// bad key). We don't show it inline — the muted hint is enough —
|
||||
// but we do log it for debugging.
|
||||
setModels([]);
|
||||
setFetchError(true);
|
||||
if (err instanceof ApiError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("listLlmModels failed:", err.message, err.details);
|
||||
}
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="label" htmlFor="setting-llm.model">llm.model</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="setting-llm.model"
|
||||
className="input flex-1"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="gpt-4o-mini"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleFetch}
|
||||
loading={fetching}
|
||||
disabled={fetching}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("admin.fetch_models")}
|
||||
</Button>
|
||||
</div>
|
||||
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
|
||||
{models && models.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<label className="label text-xs" htmlFor="llm-model-select">
|
||||
{t("admin.fetched_models")} ({models.length})
|
||||
</label>
|
||||
<select
|
||||
id="llm-model-select"
|
||||
className="input"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) onChange(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("admin.pick_model")}
|
||||
</option>
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{fetchError && (
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{t("admin.fetch_models_failed")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text Replacements card.
|
||||
// ============================================================================
|
||||
|
||||
interface TextReplacementRule {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface TextReplacementsCardProps {
|
||||
rawValue: string;
|
||||
onSave: (serializedJson: string) => void;
|
||||
}
|
||||
|
||||
/** Parse the persisted JSON string into a list of rules. Tolerates
|
||||
* malformed / empty input by returning an empty list. */
|
||||
function parseReplacements(raw: string): TextReplacementRule[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((item): TextReplacementRule | null => {
|
||||
if (item && typeof item === "object") {
|
||||
const obj = item as { from?: unknown; to?: unknown };
|
||||
return {
|
||||
from: typeof obj.from === "string" ? obj.from : "",
|
||||
to: typeof obj.to === "string" ? obj.to : "",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((x): x is TextReplacementRule => x !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function serializeReplacements(rules: TextReplacementRule[]): string {
|
||||
return JSON.stringify(rules.map((r) => ({ from: r.from, to: r.to })));
|
||||
}
|
||||
|
||||
function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
// Local working copy — only committed to parent draft when Save is
|
||||
// clicked. This avoids marking the LLM group as dirty on every keystroke.
|
||||
const [rules, setRules] = useState<TextReplacementRule[]>(() => parseReplacements(rawValue));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Re-sync from the persisted value if it changes externally (e.g. after
|
||||
// a successful save the parent passes back the masked value, which for
|
||||
// this key is the same JSON we just wrote).
|
||||
useEffect(() => {
|
||||
setRules(parseReplacements(rawValue));
|
||||
}, [rawValue]);
|
||||
|
||||
const dirty = serializeReplacements(rules) !== rawValue;
|
||||
|
||||
const addRule = () => {
|
||||
setRules((r) => [...r, { from: "", to: "" }]);
|
||||
};
|
||||
|
||||
const removeRule = (idx: number) => {
|
||||
setRules((r) => r.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const updateRule = (idx: number, field: "from" | "to", value: string) => {
|
||||
setRules((r) => r.map((rule, i) => (i === idx ? { ...rule, [field]: value } : rule)));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(serializeReplacements(rules));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t("admin.text_replacements_title")}
|
||||
description={t("admin.text_replacements_help")}
|
||||
actions={
|
||||
<Button size="sm" onClick={handleSave} loading={saving} disabled={!dirty}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{rules.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("admin.text_replacements_empty")}</p>
|
||||
)}
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder={t("admin.text_replacements_from")}
|
||||
value={rule.from}
|
||||
onChange={(e) => updateRule(idx, "from", e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">→</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input flex-1"
|
||||
placeholder={t("admin.text_replacements_to")}
|
||||
value={rule.to}
|
||||
onChange={(e) => updateRule(idx, "to", e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeRule(idx)}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" size="sm" variant="secondary" onClick={addRule}>
|
||||
+ {t("admin.text_replacements_add")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ export interface PhaseProgressProps {
|
||||
/** Phases that have started (key) with display names. */
|
||||
phases: Array<{ phase: string; name?: string; done?: boolean }>;
|
||||
currentPhase?: string;
|
||||
step?: number;
|
||||
/** Step index. Normally numeric, but the world builder also emits
|
||||
* string stage identifiers like "skipping_schema" when resuming —
|
||||
* non-numeric values are ignored for the "Step X of Y" display. */
|
||||
step?: number | string;
|
||||
totalSteps?: number;
|
||||
message?: string;
|
||||
className?: string;
|
||||
|
||||
@@ -201,8 +201,11 @@ export function IntroSceneGenerator({ world, onWorldUpdated }: IntroSceneGenerat
|
||||
void start();
|
||||
};
|
||||
|
||||
// Hide the generator once the world is ready.
|
||||
if (world.status === "ready") return null;
|
||||
// Hide the generator once the world is ready AND has an intro scene.
|
||||
// We keep showing it for "ready" worlds that somehow have no intro_scene
|
||||
// (e.g. legacy data, or intro generation completed but the scene was
|
||||
// never persisted) so the user can regenerate.
|
||||
if (world.status === "ready" && world.intro_scene) return null;
|
||||
|
||||
const busy = state.phase === "starting" || state.phase === "streaming";
|
||||
|
||||
|
||||
@@ -21,15 +21,23 @@ import { SseStatus } from "@/components/sessions/SseStatus";
|
||||
|
||||
type Mode = "preset" | "form";
|
||||
|
||||
/** Kind of builder log entry — drives the color used to render it. */
|
||||
type BuilderLogKind = "info" | "skip" | "error" | "warn";
|
||||
|
||||
interface BuilderLogEntry {
|
||||
text: string;
|
||||
kind: BuilderLogKind;
|
||||
}
|
||||
|
||||
interface BuilderState {
|
||||
phase: "form" | "building" | "done" | "error";
|
||||
currentPhase?: string;
|
||||
phases: Array<{ phase: string; name?: string; done: boolean }>;
|
||||
step?: number;
|
||||
step?: number | string;
|
||||
totalSteps?: number;
|
||||
message?: string;
|
||||
introScene: string;
|
||||
logs: string[];
|
||||
logs: BuilderLogEntry[];
|
||||
sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
|
||||
}
|
||||
|
||||
@@ -131,7 +139,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
...s,
|
||||
phase: "error",
|
||||
sseStatus: "error",
|
||||
logs: [...s.logs, `[error] ${d?.message || "Stream error"}`],
|
||||
logs: [...s.logs, { text: `[error] ${d?.message || "Stream error"}`, kind: "error" as const }],
|
||||
}));
|
||||
pushToast("error", d?.message || t("builder.build_failed"));
|
||||
controllerRef.current?.close();
|
||||
@@ -139,16 +147,29 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
}
|
||||
case "warning": {
|
||||
const d = event.data as { message?: string };
|
||||
setState((s) => ({ ...s, logs: [...s.logs, `[warn] ${d?.message || ""}`] }));
|
||||
setState((s) => ({ ...s, logs: [...s.logs, { text: `[warn] ${d?.message || ""}`, kind: "warn" as const }] }));
|
||||
break;
|
||||
}
|
||||
case "step": {
|
||||
const d = event.data as { step: number; message: string };
|
||||
// The `step` field is normally a numeric index, but the builder
|
||||
// also emits string stage identifiers like "skipping_schema" when
|
||||
// resuming a partially-built world (those stages already exist
|
||||
// and are skipped). We render those in a muted blue so they're
|
||||
// visually distinct from "real" progress steps.
|
||||
const d = event.data as { step?: number | string; message?: string };
|
||||
const stepVal = d.step;
|
||||
const isSkip = typeof stepVal === "string" && stepVal.startsWith("skipping_");
|
||||
setState((s) => ({
|
||||
...s,
|
||||
step: d.step,
|
||||
step: stepVal,
|
||||
message: d.message,
|
||||
logs: [...s.logs, `[${d.step}] ${d.message}`],
|
||||
logs: [
|
||||
...s.logs,
|
||||
{
|
||||
text: `[${stepVal ?? "?"}] ${d.message || ""}`,
|
||||
kind: isSkip ? ("skip" as const) : ("info" as const),
|
||||
},
|
||||
],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
@@ -181,13 +202,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
break;
|
||||
}
|
||||
case "world_schema_generated":
|
||||
setState((s) => ({ ...s, logs: [...s.logs, t("builder.schema_generated")] }));
|
||||
setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.schema_generated"), kind: "info" as const }] }));
|
||||
break;
|
||||
case "environment_generated":
|
||||
setState((s) => ({ ...s, logs: [...s.logs, t("builder.environment_generated")] }));
|
||||
setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.environment_generated"), kind: "info" as const }] }));
|
||||
break;
|
||||
case "entities_generated":
|
||||
setState((s) => ({ ...s, logs: [...s.logs, t("builder.entities_generated")] }));
|
||||
setState((s) => ({ ...s, logs: [...s.logs, { text: t("builder.entities_generated"), kind: "info" as const }] }));
|
||||
break;
|
||||
case "intro_scene_chunk": {
|
||||
const d = event.data as { text: string };
|
||||
@@ -464,9 +485,24 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
{state.logs.length > 0 && (
|
||||
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-muted">Logs ({state.logs.length})</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto text-[10px] text-fg-dim">
|
||||
{state.logs.join("\n")}
|
||||
</pre>
|
||||
<div className="mt-2 max-h-48 overflow-auto font-mono text-[10px] leading-relaxed">
|
||||
{state.logs.map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words",
|
||||
entry.kind === "error" && "text-err",
|
||||
entry.kind === "warn" && "text-warn",
|
||||
// Skipped stages (resumable builder) get a muted blue
|
||||
// so they're visually distinct from real progress.
|
||||
entry.kind === "skip" && "text-sky-500 dark:text-sky-400",
|
||||
entry.kind === "info" && "text-fg-dim",
|
||||
)}
|
||||
>
|
||||
{entry.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{state.phase === "done" && (
|
||||
|
||||
@@ -45,6 +45,9 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
|
||||
const isArchived = world.status === "archived";
|
||||
const isDraft = world.status === "draft";
|
||||
const isAdmin = !!user?.is_admin;
|
||||
// Prefer the human-readable time string ("Day 1, 08:00"); fall back to the
|
||||
// raw `current_time` value when the backend doesn't provide the human form.
|
||||
const displayedTime = world.current_time_human || world.current_time;
|
||||
|
||||
const handleRestore = async () => {
|
||||
setRestoring(true);
|
||||
@@ -103,7 +106,7 @@ export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, c
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-fg-dim">{t("worlds.current_time")}</dt>
|
||||
<dd className="text-fg truncate">{world.current_time || "—"}</dd>
|
||||
<dd className="text-fg truncate">{displayedTime || "—"}</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-fg-dim">{t("worlds.last_played")}</dt>
|
||||
|
||||
@@ -66,7 +66,14 @@
|
||||
"register_failed": "Registration failed",
|
||||
"session_expired": "Session expired, please sign in again.",
|
||||
"username_hint": "Letters, numbers, and underscores only. No @ or other special characters.",
|
||||
"username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed."
|
||||
"username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed.",
|
||||
"recover_title": "Recover admin access",
|
||||
"recover_help": "Disaster-recovery: creates a new admin account using the setup token. Use this when all admins have lost access.",
|
||||
"recover_token_hint": "The setup token from the server config (admin.setup_token).",
|
||||
"recover_button": "Create admin",
|
||||
"recover_success": "Admin created. You can now log in.",
|
||||
"recover_failed": "Recovery failed",
|
||||
"recover_link": "Lost admin access? Recover here"
|
||||
},
|
||||
"worlds": {
|
||||
"title": "Your Worlds",
|
||||
@@ -232,6 +239,8 @@
|
||||
"logs_filter_apply": "Apply filters",
|
||||
"logs_stage": "Stage",
|
||||
"logs_status": "Status",
|
||||
"logs_world": "World",
|
||||
"logs_filtered": "filtered",
|
||||
"logs_latency": "Latency",
|
||||
"logs_tokens": "Tokens",
|
||||
"logs_created": "Created",
|
||||
@@ -282,7 +291,17 @@
|
||||
"tool_calls_detected": "Tool calls detected",
|
||||
"no_tool_calls_warning_title": "No tool calls returned",
|
||||
"no_tool_calls_warning": "Model did not return tool calls. This may mean the model doesn't support function calling, or uses a non-standard format.",
|
||||
"raw_response": "Raw LLM response"
|
||||
"raw_response": "Raw LLM response",
|
||||
"fetch_models": "Fetch models",
|
||||
"fetched_models": "Available models",
|
||||
"pick_model": "Pick a model…",
|
||||
"fetch_models_failed": "Could not fetch model list. Enter the model name manually.",
|
||||
"text_replacements_title": "Text Replacements",
|
||||
"text_replacements_help": "These replacements are applied to all LLM scene text output. Use empty 'To' to remove a word/phrase entirely.",
|
||||
"text_replacements_empty": "No replacement rules yet. Click 'Add rule' to create one.",
|
||||
"text_replacements_from": "From",
|
||||
"text_replacements_to": "To",
|
||||
"text_replacements_add": "Add rule"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong.",
|
||||
|
||||
@@ -66,7 +66,14 @@
|
||||
"register_failed": "Не удалось зарегистрироваться",
|
||||
"session_expired": "Сессия истекла, пожалуйста, войдите снова.",
|
||||
"username_hint": "Только буквы, цифры и подчёркивание. Без @ и других спецсимволов.",
|
||||
"username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются."
|
||||
"username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются.",
|
||||
"recover_title": "Восстановление доступа администратора",
|
||||
"recover_help": "Аварийное восстановление: создаёт новый аккаунт администратора через установочный токен. Используйте, когда все администраторы потеряли доступ.",
|
||||
"recover_token_hint": "Установочный токен из конфигурации сервера (admin.setup_token).",
|
||||
"recover_button": "Создать администратора",
|
||||
"recover_success": "Администратор создан. Теперь можно войти.",
|
||||
"recover_failed": "Не удалось восстановить доступ",
|
||||
"recover_link": "Потеряли доступ администратора? Восстановить здесь"
|
||||
},
|
||||
"worlds": {
|
||||
"title": "Ваши миры",
|
||||
@@ -232,6 +239,8 @@
|
||||
"logs_filter_apply": "Применить фильтры",
|
||||
"logs_stage": "Стадия",
|
||||
"logs_status": "Статус",
|
||||
"logs_world": "Мир",
|
||||
"logs_filtered": "фильтр",
|
||||
"logs_latency": "Задержка",
|
||||
"logs_tokens": "Токены",
|
||||
"logs_created": "Создано",
|
||||
@@ -282,7 +291,17 @@
|
||||
"tool_calls_detected": "Обнаружены вызовы инструментов",
|
||||
"no_tool_calls_warning_title": "Вызовы инструментов не возвращены",
|
||||
"no_tool_calls_warning": "Модель не вернула вызовы инструментов. Это может означать, что модель не поддерживает function calling или использует нестандартный формат.",
|
||||
"raw_response": "Полный ответ модели"
|
||||
"raw_response": "Полный ответ модели",
|
||||
"fetch_models": "Получить модели",
|
||||
"fetched_models": "Доступные модели",
|
||||
"pick_model": "Выберите модель…",
|
||||
"fetch_models_failed": "Не удалось получить список моделей. Введите имя модели вручную.",
|
||||
"text_replacements_title": "Замены текста",
|
||||
"text_replacements_help": "Эти замены применяются ко всему выводу LLM-сцены. Пустое 'На' полностью удаляет слово/фразу.",
|
||||
"text_replacements_empty": "Правил замены пока нет. Нажмите «Добавить правило», чтобы создать.",
|
||||
"text_replacements_from": "С",
|
||||
"text_replacements_to": "На",
|
||||
"text_replacements_add": "Добавить правило"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Что-то пошло не так.",
|
||||
|
||||
@@ -353,7 +353,7 @@ export const SessionsApi = {
|
||||
* lives in the sessions API surface for grouping.)
|
||||
*/
|
||||
generateIntro: (worldId: string) =>
|
||||
request<GenerateIntroResponse>(`/worlds/${worldId}/generate-intro`, { method: "POST" }),
|
||||
request<GenerateIntroResponse>(`/sessions/worlds/${worldId}/generate-intro`, { method: "POST" }),
|
||||
|
||||
// SSE stream URLs (used by SSE client)
|
||||
iterateStreamUrl: (worldId: string, stepId: string) =>
|
||||
@@ -387,7 +387,58 @@ export type LlmLogsQuery = {
|
||||
per_page?: number;
|
||||
};
|
||||
|
||||
/** Body for POST /api/admin/recover (NO AUTH). */
|
||||
export interface AdminRecoverPayload {
|
||||
token: string;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** Response from POST /api/admin/recover. */
|
||||
export interface AdminRecoverResponse {
|
||||
ok: boolean;
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
/** Response from POST /api/admin/llm/models. */
|
||||
export interface LlmModelsResponse {
|
||||
ok: boolean;
|
||||
models?: string[];
|
||||
count?: number;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
export const AdminApi = {
|
||||
/**
|
||||
* Disaster-recovery admin creation. NO AUTH required — uses a setup token.
|
||||
* Use when all admins have lost access. The route is mounted publicly by
|
||||
* the backend.
|
||||
*/
|
||||
recover: (body: AdminRecoverPayload) =>
|
||||
request<AdminRecoverResponse>("/admin/recover", { method: "POST", body }),
|
||||
|
||||
/**
|
||||
* Fetches the list of available models from the configured LLM provider.
|
||||
* Pass the api_url / api_key currently entered in the settings form so the
|
||||
* backend can probe the provider directly (it does NOT read saved settings
|
||||
* for this call — the user may have typed but not yet saved).
|
||||
*/
|
||||
listLlmModels: (apiUrl?: string, apiKey?: string) =>
|
||||
request<LlmModelsResponse>(
|
||||
"/admin/llm/models",
|
||||
{
|
||||
method: "POST",
|
||||
query: {
|
||||
api_url: apiUrl || undefined,
|
||||
api_key: apiKey || undefined,
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
settings: () => request<AdminSettingsResponse>("/admin/settings"),
|
||||
updateSettings: (settings: Record<string, string>) =>
|
||||
request<{ updated: Record<string, string> }>("/admin/settings", { method: "PATCH", body: { settings } }),
|
||||
|
||||
143
frontend/src/pages/AdminRecoverPage.tsx
Normal file
143
frontend/src/pages/AdminRecoverPage.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi, ApiError, toErrorMessage } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
/**
|
||||
* Disaster-recovery admin creation page. Calls POST /api/admin/recover
|
||||
* with a setup token (the same kind of token used by /register/admin).
|
||||
* NO AUTH required — this route is public on the backend so it can be
|
||||
* used when all admins have lost access.
|
||||
*
|
||||
* On success → redirect to /login with a success toast.
|
||||
* On error → show the backend error message inline (e.g.
|
||||
* "invalid_admin_token", "email_already_exists").
|
||||
*/
|
||||
export function AdminRecoverPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const [token, setToken] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [topError, setTopError] = useState<string | null>(null);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!token.trim()) next.token = t("errors.validation");
|
||||
if (!email.includes("@")) next.email = t("errors.validation");
|
||||
if (username.trim().length < 3) next.username = t("errors.validation");
|
||||
if (password.length < 8) next.password = t("errors.validation");
|
||||
if (password !== passwordConfirm) next.password_confirm = t("errors.validation");
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setTopError(null);
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await AdminApi.recover({
|
||||
token: token.trim(),
|
||||
email: email.trim(),
|
||||
username: username.trim(),
|
||||
password,
|
||||
});
|
||||
pushToast("success", t("auth.recover_success"));
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
// Backend returns error codes like "invalid_admin_token",
|
||||
// "email_already_exists", etc. Surface them inline + as a toast.
|
||||
let message: string;
|
||||
if (err instanceof ApiError) {
|
||||
message = err.message;
|
||||
} else {
|
||||
message = toErrorMessage(err, t("auth.recover_failed"));
|
||||
}
|
||||
setTopError(message);
|
||||
pushToast("error", message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-md items-center p-4">
|
||||
<Card className="w-full" title={t("auth.recover_title")}>
|
||||
<p className="mb-3 text-xs text-fg-muted">
|
||||
{t("auth.recover_help")}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
label={t("auth.admin_token")}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
error={errors.token}
|
||||
hint={t("auth.recover_token_hint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.email")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
error={errors.email}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
error={errors.username}
|
||||
hint={t("auth.username_hint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password_confirm")}
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password_confirm}
|
||||
/>
|
||||
{topError && (
|
||||
<p className="rounded-md border border-err/30 bg-err/10 p-2 text-sm text-err">
|
||||
{topError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{t("auth.recover_button")}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-fg-muted">
|
||||
<Link to="/login" className="text-accent hover:underline">
|
||||
{t("auth.have_account")}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,11 @@ export function LoginPage() {
|
||||
{t("auth.no_account")}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-center text-xs text-fg-dim">
|
||||
<Link to="/recover" className="text-fg-muted hover:text-accent hover:underline">
|
||||
{t("auth.recover_link")}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -206,7 +206,13 @@ export function PlayPage() {
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">{world.name}</h2>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{world.current_time ? `${t("worlds.current_time")}: ${world.current_time}` : ""}
|
||||
{(() => {
|
||||
// Prefer the human-readable form; fall back to the raw
|
||||
// current_time string when the backend doesn't supply it
|
||||
// (e.g. older session state cached locally).
|
||||
const time = world.current_time_human || world.current_time;
|
||||
return time ? `${t("worlds.current_time")}: ${time}` : "";
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<SseStatus status={sseStatus} />
|
||||
|
||||
@@ -66,13 +66,27 @@ export function WorldEditPage() {
|
||||
}
|
||||
|
||||
const isDraft = world.status === "draft";
|
||||
// Show the IntroSceneGenerator whenever the world is still a draft (the
|
||||
// intro scene is the thing that flips a draft → ready) OR whenever the
|
||||
// intro scene is missing for any reason (e.g. legacy data, interrupted
|
||||
// build). After successful intro generation, `refreshWorld` updates the
|
||||
// world to status "ready" with intro_scene set, and IntroSceneGenerator
|
||||
// then returns null on its next render.
|
||||
const showIntroGenerator = world.status === "draft" || !world.intro_scene;
|
||||
// Prefer the human-readable time string; fall back to the raw value.
|
||||
const displayedTime = world.current_time_human || world.current_time;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1>
|
||||
<p className="text-sm text-fg-muted">{world.name}</p>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{world.name}
|
||||
{displayedTime && (
|
||||
<span className="ml-2 text-fg-dim">· {t("worlds.current_time")}: {displayedTime}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -85,7 +99,7 @@ export function WorldEditPage() {
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{isDraft && (
|
||||
{showIntroGenerator && (
|
||||
<IntroSceneGenerator world={world} onWorldUpdated={() => void refreshWorld()} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ export interface WorldListItem {
|
||||
status: WorldStatus;
|
||||
last_played_at: string | null;
|
||||
current_time: string | null;
|
||||
/** Human-readable form of `current_time` (e.g. "Day 1, 08:00"). May be
|
||||
* absent on older backend versions — fall back to `current_time`. */
|
||||
current_time_human?: string | null;
|
||||
created_at: string;
|
||||
preview_player_name: string | null;
|
||||
}
|
||||
@@ -125,6 +128,10 @@ export interface World {
|
||||
environment: Environment;
|
||||
plot_rails: PlotRail[];
|
||||
current_time: string | null;
|
||||
/** Human-readable form of `current_time` (e.g. "Day 1, 08:00").
|
||||
* Returned by GET /api/sessions/worlds/{id}/state on the world object.
|
||||
* May be absent on older backend versions — fall back to `current_time`. */
|
||||
current_time_human?: string | null;
|
||||
status: WorldStatus;
|
||||
intro_scene: string | null;
|
||||
created_at: string;
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/IntroSceneGenerator.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/IntroSceneGenerator.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRecoverPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user