This commit is contained in:
Mikan
2026-06-21 02:41:48 +03:00
parent c21a13d2a3
commit bd85e186dc
31 changed files with 1372 additions and 319 deletions

View File

@@ -8,6 +8,7 @@ import {
} from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/authStore";
import { useUiSettingsStore } from "@/stores/uiSettingsStore";
import { Navbar } from "@/components/ui/Navbar";
import { ToastViewport } from "@/components/ui/Toast";
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
@@ -58,9 +59,18 @@ function ScrollToTop() {
export default function App() {
const { t } = useTranslation();
const uiSettings = useUiSettingsStore((s) => s.settings);
// On mount, fetch public UI settings (page title, favicon, logo).
const fetchUiSettings = useUiSettingsStore((s) => s.fetch);
useEffect(() => {
document.title = t("common.app_name");
}, [t]);
void fetchUiSettings();
}, [fetchUiSettings]);
// Keep document.title in sync with public settings (or default to app name).
useEffect(() => {
if (uiSettings?.page_title) document.title = uiSettings.page_title;
else document.title = t("common.app_name");
}, [uiSettings?.page_title, t]);
return (
<BrowserRouter>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminApi } from "@/lib/api";
import { AdminApi, toErrorMessage } from "@/lib/api";
import { useToastStore } from "@/stores/toastStore";
import type { LlmLog, LlmLogDetail, Paginated } from "@/types";
import { Button } from "@/components/ui/Button";
@@ -9,6 +9,34 @@ import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Spinner } from "@/components/ui/Spinner";
const STAGE_OPTIONS = [
"",
"world_builder_schema",
"world_builder_env",
"world_builder_entities",
"world_editor",
"orchestrator_phase1",
"orchestrator_phase2",
"orchestrator_phase3_summary",
"orchestrator_phase3_suggest",
"intro_scene",
"subagent",
"summary",
"test_llm",
"test_llm_tools",
"test_embeddings",
"test_embeddings_probe_dimension",
];
const STATUS_OPTIONS = ["", "ok", "timeout", "api_error", "parse_error", "validation_error"];
function statusColor(status: string): string {
if (status === "ok") return "bg-ok/15 text-ok";
const errKinds = ["timeout", "api_error", "parse_error", "validation_error", "error"];
if (errKinds.includes(status)) return "bg-err/15 text-err";
return "bg-warn/15 text-warn";
}
export function LlmLogsTable() {
const { t } = useTranslation();
const pushToast = useToastStore((s) => s.push);
@@ -35,7 +63,7 @@ export function LlmLogsTable() {
});
setData(res);
} catch (err) {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err));
} finally {
setLoading(false);
}
@@ -53,11 +81,12 @@ export function LlmLogsTable() {
const openDetail = async (id: string) => {
setDetailOpen(true);
setDetailLoading(true);
setDetail(null);
try {
const d = await AdminApi.llmLog(id);
setDetail(d);
} catch (err) {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err));
} finally {
setDetailLoading(false);
}
@@ -73,18 +102,32 @@ export function LlmLogsTable() {
onChange={(e) => setFilters({ ...filters, world_id: e.target.value })}
placeholder="uuid"
/>
<Input
label={t("admin.logs_filter_stage")}
value={filters.stage}
onChange={(e) => setFilters({ ...filters, stage: e.target.value })}
placeholder="world_builder / iteration / ..."
/>
<Input
label={t("admin.logs_filter_status")}
value={filters.status_filter}
onChange={(e) => setFilters({ ...filters, status_filter: e.target.value })}
placeholder="success / error"
/>
<div className="w-full">
<label className="label" htmlFor="filter-stage">{t("admin.logs_filter_stage")}</label>
<select
id="filter-stage"
className="input"
value={filters.stage}
onChange={(e) => setFilters({ ...filters, stage: e.target.value })}
>
{STAGE_OPTIONS.map((s) => (
<option key={s || "_empty"} value={s}>{s || "(any)"}</option>
))}
</select>
</div>
<div className="w-full">
<label className="label" htmlFor="filter-status">{t("admin.logs_filter_status")}</label>
<select
id="filter-status"
className="input"
value={filters.status_filter}
onChange={(e) => setFilters({ ...filters, status_filter: e.target.value })}
>
{STATUS_OPTIONS.map((s) => (
<option key={s || "_empty"} value={s}>{s || "(any)"}</option>
))}
</select>
</div>
<div className="flex items-end">
<Button onClick={applyFilters} variant="secondary" fullWidth>
{t("admin.logs_filter_apply")}
@@ -118,15 +161,7 @@ export function LlmLogsTable() {
<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">
<span
className={`badge ${
log.status === "success"
? "bg-ok/15 text-ok"
: log.status === "error"
? "bg-err/15 text-err"
: "bg-warn/15 text-warn"
}`}
>
<span className={`badge ${statusColor(log.status)}`}>
{log.status}
</span>
</td>
@@ -134,7 +169,7 @@ export function LlmLogsTable() {
{log.latency_ms != null ? `${log.latency_ms} ms` : "—"}
</td>
<td className="p-2 text-fg-muted">
{log.tokens != null ? log.tokens : "—"}
{formatTokens(log)}
</td>
<td className="p-2 text-fg-muted">{formatDate(log.created_at)}</td>
<td className="p-2 text-right">
@@ -187,37 +222,64 @@ export function LlmLogsTable() {
</div>
) : detail ? (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<Field label={t("admin.logs_stage")} value={detail.stage} />
<Field label={t("admin.logs_status")} value={detail.status} />
<Field
label={t("admin.logs_status")}
value={detail.status}
badgeClass={statusColor(detail.status)}
/>
<Field label={t("admin.model")} value={detail.model || "—"} />
<Field
label={t("admin.logs_latency")}
value={detail.latency_ms != null ? `${detail.latency_ms} ms` : "—"}
/>
<Field
label={t("admin.logs_tokens")}
value={detail.tokens != null ? String(detail.tokens) : "—"}
label="prompt_tokens"
value={detail.prompt_tokens != null ? String(detail.prompt_tokens) : "—"}
/>
<Field
label="completion_tokens"
value={detail.completion_tokens != null ? String(detail.completion_tokens) : "—"}
/>
<Field
label="temperature"
value={detail.temperature != null ? String(detail.temperature) : "—"}
/>
<Field
label="tool_calls"
value={detail.tool_calls == null
? "—"
: Array.isArray(detail.tool_calls)
? `${detail.tool_calls.length} call(s)`
: "present"}
/>
<Field label={t("common.name")} value={detail.model || "—"} />
<Field label={t("admin.logs_created")} value={formatDate(detail.created_at)} />
</div>
{detail.error && (
<Section title={t("admin.logs_error")}>
<pre className="whitespace-pre-wrap text-err">{detail.error}</pre>
</Section>
)}
{detail.prompt && (
<Section title={t("admin.logs_prompt")}>
<pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs">
{detail.prompt}
</pre>
</Section>
)}
{detail.response && (
<Section title={t("admin.logs_response")}>
<pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs">
{detail.response}
</pre>
<Section title={t("admin.logs_error")}>
{detail.error_message ? (
<pre className="whitespace-pre-wrap text-err">{detail.error_message}</pre>
) : (
<p className="text-fg-muted"></p>
)}
</Section>
<Section title="request_messages">
<JsonBlock value={detail.request_messages} />
</Section>
<Section title="response_message">
<JsonBlock value={detail.response_message} />
</Section>
<Section title="tool_calls">
<JsonBlock value={detail.tool_calls} />
</Section>
{detail.request_tools != null && (
<Section title="request_tools">
<JsonBlock value={detail.request_tools} />
</Section>
)}
</div>
@@ -229,11 +291,43 @@ export function LlmLogsTable() {
);
}
function Field({ label, value }: { label: string; value: string }) {
function formatTokens(log: LlmLog): string {
const p = log.prompt_tokens;
const c = log.completion_tokens;
if (p != null && c != null) return `${p} / ${c}`;
if (p != null) return String(p);
if (c != null) return String(c);
// Legacy `tokens` field may exist on older rows.
const t = (log as { tokens?: number | null }).tokens;
return t != null ? String(t) : "—";
}
function JsonBlock({ value }: { value: unknown }) {
if (value == null) {
return <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs text-fg-muted"></pre>;
}
let text: string;
try {
text = typeof value === "string" ? JSON.stringify(JSON.parse(value), null, 2) : JSON.stringify(value, null, 2);
} catch {
text = String(value);
}
return (
<pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs">
{text}
</pre>
);
}
function Field({ label, value, badgeClass }: { label: string; value: string; badgeClass?: string }) {
return (
<div>
<p className="text-xs uppercase text-fg-dim">{label}</p>
<p className="text-fg break-all">{value}</p>
{badgeClass ? (
<span className={`badge mt-0.5 ${badgeClass}`}>{value}</span>
) : (
<p className="text-fg break-all">{value}</p>
)}
</div>
);
}

View File

@@ -2,26 +2,111 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminApi } from "@/lib/api";
import { useToastStore } from "@/stores/toastStore";
import { refreshUiSettings } from "@/stores/uiSettingsStore";
import type { AdminSettingsResponse } from "@/types";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Spinner } from "@/components/ui/Spinner";
const GROUP_PREFIXES: Array<{ group: string; prefixes: string[]; labelKey: string }> = [
{ group: "llm", prefixes: ["llm_", "llm."], labelKey: "admin.group_llm" },
{ group: "embeddings", prefixes: ["embeddings_", "embeddings."], labelKey: "admin.group_embeddings" },
{ group: "qdrant", prefixes: ["qdrant_", "qdrant."], labelKey: "admin.group_qdrant" },
{ group: "ui", prefixes: ["ui_", "ui.", "site_", "site."], labelKey: "admin.group_ui" },
{ group: "game", prefixes: ["game_", "game."], labelKey: "admin.group_game" },
interface GroupDef {
id: string;
labelKey: string;
/** Match by setting-key prefix (e.g. "llm." or "llm_"). */
prefixes: string[];
}
const GROUPS: GroupDef[] = [
{ id: "llm", labelKey: "admin.group_llm", prefixes: ["llm.", "llm_"] },
{ id: "embeddings", labelKey: "admin.group_embeddings", prefixes: ["embeddings.", "embeddings_"] },
{ id: "qdrant", labelKey: "admin.group_qdrant", prefixes: ["qdrant.", "qdrant_"] },
{ id: "context", labelKey: "admin.group_context", prefixes: ["context.", "context_"] },
{ id: "game", labelKey: "admin.group_game", prefixes: ["game.", "game_"] },
{ id: "ui", labelKey: "admin.group_ui", prefixes: ["ui.", "ui_", "site.", "site_"] },
];
function groupFor(key: string): string {
function groupFor(key: string): GroupDef | null {
const lower = key.toLowerCase();
for (const g of GROUP_PREFIXES) {
if (g.prefixes.some((p) => lower.startsWith(p))) return g.group;
for (const g of GROUPS) {
if (g.prefixes.some((p) => lower.startsWith(p))) return g;
}
return "other";
return null;
}
/** Field types — drives which control is rendered. */
type FieldType = "integer" | "float" | "boolean" | "provider" | "secret" | "text";
const INTEGER_KEYS = new Set<string>([
"llm.max_tokens",
"llm.timeout_seconds",
"embeddings.dimension",
"embeddings.batch_size",
"embeddings.timeout_seconds",
"embeddings.cache_ttl_seconds",
"embeddings.max_text_chars",
"context.guaranteed_messages",
"context.compression_threshold_messages",
"context.compression_threshold_tokens",
"context.scene_text_truncate_tokens",
"context.safety_margin_tokens",
"game.max_substeps_per_iteration",
"game.max_suggested_actions",
]);
const FLOAT_KEYS = new Set<string>([
"llm.temperature_orchestrator",
"llm.temperature_writer",
]);
const BOOLEAN_KEYS = new Set<string>([
"game.deferred_triggers_enabled",
"context.auto_rag_on_entity_mention",
]);
const SECRET_KEYS = new Set<string>([
"llm.api_key",
"embeddings.api_key",
"qdrant.api_key",
"admin.setup_token",
]);
const PROVIDER_KEYS = new Set<string>(["embeddings.provider"]);
function fieldType(key: string): FieldType {
if (PROVIDER_KEYS.has(key)) return "provider";
if (BOOLEAN_KEYS.has(key)) return "boolean";
if (INTEGER_KEYS.has(key)) return "integer";
if (FLOAT_KEYS.has(key)) return "float";
if (SECRET_KEYS.has(key)) return "secret";
return "text";
}
/** Returns the appropriate hint text for a given setting key, if any. */
function hintFor(key: string): string | undefined {
switch (key) {
case "embeddings.api_url":
case "embeddings.api_key":
return "If empty, falls back to llm.api_url / llm.api_key";
case "embeddings.model":
return "Default: text-embedding-3-small";
case "embeddings.provider":
return "If provider=openai and api_url is empty, the system falls back to llm.api_url";
default:
return undefined;
}
}
function castValue(key: string, raw: string): string {
const ft = fieldType(key);
if (ft === "integer") {
const n = parseInt(raw, 10);
return Number.isFinite(n) ? String(n) : raw;
}
if (ft === "float") {
const n = parseFloat(raw);
return Number.isFinite(n) ? String(n) : raw;
}
return raw;
}
export function SettingsPanel() {
@@ -29,52 +114,88 @@ export function SettingsPanel() {
const pushToast = useToastStore((s) => s.push);
const [data, setData] = useState<AdminSettingsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [draft, setDraft] = useState<Record<string, string>>({});
useEffect(() => {
let cancelled = false;
setLoading(true);
AdminApi.settings()
.then((res) => {
if (cancelled) return;
setData(res);
setDraft({ ...res.settings });
})
.catch(() => pushToast("error", t("admin.settings_load_failed")))
.finally(() => setLoading(false));
}, [pushToast, t]);
.catch((err) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : "Failed to load settings";
pushToast("error", msg);
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [pushToast]);
const grouped = useMemo(() => {
if (!data) return {} as Record<string, Array<{ key: string; description?: string }>>;
const out: Record<string, Array<{ key: string; description?: string }>> = {};
if (!data) return out;
for (const g of GROUPS) out[g.id] = [];
for (const key of Object.keys(data.settings)) {
const g = groupFor(key);
(out[g] ||= []).push({ key, description: data.descriptions?.[key] });
if (!g) continue;
(out[g.id] ||= []).push({ key, description: data.descriptions?.[key] });
}
// Sort each group's keys alphabetically for stable display.
for (const g of Object.keys(out)) {
out[g].sort((a, b) => a.key.localeCompare(b.key));
}
return out;
}, [data]);
const handleSave = async () => {
const handleSaveGroup = async (groupId: string) => {
if (!data) return;
setSaving(true);
const entries = grouped[groupId] || [];
const groupKeys = new Set(entries.map((e) => e.key));
// Build a diff of changed keys in this group only.
const diff: Record<string, string> = {};
for (const k of groupKeys) {
const before = data.settings[k] ?? "";
const after = draft[k] ?? "";
if (before !== after) {
diff[k] = castValue(k, after);
}
}
if (Object.keys(diff).length === 0) {
pushToast("info", "No changes to save.");
return;
}
try {
// Save only changed keys
const diff: Record<string, string> = {};
for (const [k, v] of Object.entries(draft)) {
if (data.settings[k] !== v) diff[k] = v;
}
if (Object.keys(diff).length === 0) {
pushToast("info", "No changes to save.");
return;
}
const res = await AdminApi.updateSettings(diff);
setData(res);
setDraft({ ...res.settings });
// Merge returned (possibly masked) values back into local state WITHOUT
// replacing the whole settings object — this is what was causing the
// black-screen re-render loop.
const nextSettings = { ...data.settings };
for (const [k, v] of Object.entries(res.updated)) {
nextSettings[k] = v;
}
setData({ ...data, settings: nextSettings });
// Update draft to reflect masked values so future diffs are accurate.
setDraft((d) => {
const next = { ...d };
for (const k of Object.keys(res.updated)) {
next[k] = res.updated[k];
}
return next;
});
pushToast("success", t("admin.settings_saved"));
// If this group contains UI settings, refresh public UI settings so
// the page title / favicon / logo update live.
if (groupId === "ui") {
void refreshUiSettings();
}
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed";
const msg = err instanceof Error ? err.message : "Failed to save settings";
pushToast("error", msg);
} finally {
setSaving(false);
}
};
@@ -90,43 +211,178 @@ export function SettingsPanel() {
return <p className="text-sm text-fg-muted">{t("common.no_data")}</p>;
}
const groupOrder = ["llm", "embeddings", "qdrant", "ui", "game", "other"];
const groupLabelKey: Record<string, string> = {
llm: "admin.group_llm",
embeddings: "admin.group_embeddings",
qdrant: "admin.group_qdrant",
ui: "admin.group_ui",
game: "admin.group_game",
other: "common.details",
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
<Button onClick={handleSave} loading={saving}>
{t("common.save")}
</Button>
<p className="mt-1 text-xs text-fg-muted">
Each card saves independently. Secret values (api_key) are masked after save.
</p>
</div>
{groupOrder.map((g) => {
const entries = grouped[g];
{GROUPS.map((g) => {
const entries = grouped[g.id];
if (!entries || entries.length === 0) return null;
return (
<Card key={g} title={t(groupLabelKey[g] || "common.details")}>
<div className="grid gap-3 sm:grid-cols-2">
{entries.map(({ key, description }) => (
<Input
key={key}
label={key}
hint={description}
value={draft[key] ?? ""}
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.value }))}
/>
))}
</div>
</Card>
<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)}
/>
);
})}
</div>
);
}
interface SettingsGroupCardProps {
title: string;
entries: Array<{ key: string; description?: string }>;
draft: Record<string, string>;
onChange: (key: string, value: string) => void;
onSave: () => void;
}
function SettingsGroupCard({ title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await onSave();
} finally {
setSaving(false);
}
};
return (
<Card
title={title}
actions={
<Button size="sm" onClick={handleSave} loading={saving}>
{t("common.save")}
</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>
</Card>
);
}
interface SettingFieldProps {
settingKey: string;
description?: string;
value: string;
onChange: (value: string) => void;
}
function SettingField({ settingKey, description, value, onChange }: SettingFieldProps) {
const { t } = useTranslation();
const ft = fieldType(settingKey);
const hint = hintFor(settingKey) || description;
const label = settingKey;
if (ft === "boolean") {
return (
<div className="w-full">
<label className="label" htmlFor={`setting-${settingKey}`}>{label}</label>
<select
id={`setting-${settingKey}`}
className="input"
value={value === "true" ? "true" : value === "false" ? "false" : value}
onChange={(e) => onChange(e.target.value)}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
</div>
);
}
if (ft === "provider") {
return (
<div className="w-full">
<label className="label" htmlFor={`setting-${settingKey}`}>{label}</label>
<select
id={`setting-${settingKey}`}
className="input"
value={value || "offline_hash"}
onChange={(e) => onChange(e.target.value)}
>
<option value="offline_hash">offline_hash</option>
<option value="openai">openai</option>
</select>
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
</div>
);
}
if (ft === "integer") {
return (
<Input
id={`setting-${settingKey}`}
label={label}
hint={hint}
type="number"
step={1}
min={0}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
if (ft === "float") {
return (
<Input
id={`setting-${settingKey}`}
label={label}
hint={hint}
type="number"
step={0.01}
min={0}
max={2}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
if (ft === "secret") {
return (
<Input
id={`setting-${settingKey}`}
label={label}
hint={hint}
type="password"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t("admin.api_key")}
/>
);
}
return (
<Input
id={`setting-${settingKey}`}
label={label}
hint={hint}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminApi } from "@/lib/api";
import { AdminApi, toErrorMessage } from "@/lib/api";
import { useToastStore } from "@/stores/toastStore";
import type { AdminStats } from "@/types";
import { Card } from "@/components/ui/Card";
@@ -13,11 +13,15 @@ export function StatsPanel() {
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
AdminApi.stats()
.then(setStats)
.catch((err) => pushToast("error", err instanceof Error ? err.message : "Failed"))
.finally(() => setLoading(false));
.then((s) => !cancelled && setStats(s))
.catch((err) => !cancelled && pushToast("error", toErrorMessage(err)))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [pushToast]);
if (loading) {
@@ -31,9 +35,14 @@ export function StatsPanel() {
return <p className="text-sm text-fg-muted">{t("common.no_data")}</p>;
}
const archived = stats.worlds_archived ?? 0;
const total = stats.worlds_total ?? stats.worlds + archived;
const cards: Array<{ label: string; value: string | number }> = [
{ label: t("admin.stats_users"), value: stats.users },
{ label: t("admin.stats_worlds"), value: stats.worlds },
{ label: t("admin.stats_active_worlds"), value: stats.worlds },
{ label: t("admin.stats_archived"), value: archived },
{ label: `${t("admin.stats_total_worlds")}`, value: total },
{ label: t("admin.stats_steps"), value: stats.steps },
{
label: t("admin.stats_avg_latency"),
@@ -42,11 +51,11 @@ export function StatsPanel() {
];
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{cards.map((c) => (
<Card key={c.label}>
<p className="text-xs uppercase tracking-wide text-fg-muted">{c.label}</p>
<p className="mt-2 text-3xl font-bold text-fg">{c.value}</p>
<p className="mt-2 text-2xl font-bold text-fg">{c.value}</p>
</Card>
))}
</div>

View File

@@ -1,8 +1,9 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminApi } from "@/lib/api";
import { AdminApi, toErrorMessage } from "@/lib/api";
import { useToastStore } from "@/stores/toastStore";
import type {
AdminSettingsResponse,
EmbeddingsProbeResult,
EmbeddingsTestResult,
LlmTestResult,
@@ -14,6 +15,38 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Spinner } from "@/components/ui/Spinner";
const EMBEDDINGS_PROVIDERS = ["offline_hash", "openai"] as const;
type EmbeddingsProvider = (typeof EMBEDDINGS_PROVIDERS)[number];
/**
* Shared hook: fetch the current admin settings once on mount and expose the
* values so the test cards can pre-fill their form fields with the live
* configuration (llm.api_url, llm.api_key, llm.model, embeddings.*).
*/
function useSettingsDefaults(): {
loading: boolean;
settings: Record<string, string>;
} {
const [loading, setLoading] = useState(true);
const [settings, setSettings] = useState<Record<string, string>>({});
useEffect(() => {
let cancelled = false;
AdminApi.settings()
.then((res: AdminSettingsResponse) => {
if (cancelled) return;
setSettings(res.settings || {});
})
.catch(() => {
/* ignore — defaults remain empty strings */
})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, []);
return { loading, settings };
}
export function TestButtons() {
const { t } = useTranslation();
return (
@@ -30,25 +63,32 @@ export function TestButtons() {
function LlmTestCard() {
const { t } = useTranslation();
const pushToast = useToastStore((s) => s.push);
const { loading, settings } = useSettingsDefaults();
const [apiUrl, setApiUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [loading, setLoading] = useState(false);
const [loading2, setLoading2] = useState(false);
const [result, setResult] = useState<LlmTestResult | null>(null);
// Once defaults load, populate any empty fields.
useEffect(() => {
if (loading) return;
setApiUrl((cur) => cur || settings["llm.api_url"] || "");
setApiKey((cur) => cur || settings["llm.api_key"] || "");
setModel((cur) => cur || settings["llm.model"] || "");
}, [loading, settings]);
const run = async () => {
setLoading(true);
setLoading2(true);
setResult(null);
try {
const r = await AdminApi.testLlm(apiUrl, apiKey, model);
setResult(r);
if (!r.ok) pushToast("error", t("admin.failed"));
else pushToast("success", t("admin.ok"));
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : extractErr(r));
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed";
pushToast("error", msg);
pushToast("error", toErrorMessage(err));
} finally {
setLoading(false);
setLoading2(false);
}
};
@@ -60,10 +100,10 @@ function LlmTestCard() {
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} placeholder="gpt-4o-mini" />
</div>
<div className="mt-3 flex items-center gap-3">
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
{loading ? t("admin.running") : t("admin.run_test")}
<Button onClick={run} loading={loading2} disabled={!apiUrl || !model}>
{loading2 ? t("admin.running") : t("admin.run_test")}
</Button>
{loading && <Spinner size="sm" />}
{loading2 && <Spinner size="sm" />}
</div>
{result && <TestResultCard result={result} />}
</Card>
@@ -73,14 +113,22 @@ function LlmTestCard() {
function LlmToolsTestCard() {
const { t } = useTranslation();
const pushToast = useToastStore((s) => s.push);
const { loading, settings } = useSettingsDefaults();
const [apiUrl, setApiUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [loading, setLoading] = useState(false);
const [loading2, setLoading2] = useState(false);
const [result, setResult] = useState<LlmToolsTestResult | null>(null);
useEffect(() => {
if (loading) return;
setApiUrl((cur) => cur || settings["llm.api_url"] || "");
setApiKey((cur) => cur || settings["llm.api_key"] || "");
setModel((cur) => cur || settings["llm.model"] || "");
}, [loading, settings]);
const run = async () => {
setLoading(true);
setLoading2(true);
setResult(null);
try {
const r = await AdminApi.testLlmTools({
@@ -89,11 +137,11 @@ function LlmToolsTestCard() {
model,
});
setResult(r);
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed"));
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : extractErr(r));
} catch (err) {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err));
} finally {
setLoading(false);
setLoading2(false);
}
};
@@ -105,8 +153,8 @@ function LlmToolsTestCard() {
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} />
</div>
<div className="mt-3 flex items-center gap-3">
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
{loading ? t("admin.running") : t("admin.run_test")}
<Button onClick={run} loading={loading2} disabled={!apiUrl || !model}>
{loading2 ? t("admin.running") : t("admin.run_test")}
</Button>
</div>
{result && <TestResultCard result={result} />}
@@ -117,15 +165,26 @@ function LlmToolsTestCard() {
function EmbeddingsTestCard() {
const { t } = useTranslation();
const pushToast = useToastStore((s) => s.push);
const { loading, settings } = useSettingsDefaults();
const [apiUrl, setApiUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [provider, setProvider] = useState("");
const [loading, setLoading] = useState(false);
const [provider, setProvider] = useState<EmbeddingsProvider>("offline_hash");
const [loading2, setLoading2] = useState(false);
const [result, setResult] = useState<EmbeddingsTestResult | null>(null);
useEffect(() => {
if (loading) return;
setApiUrl((cur) => cur || settings["embeddings.api_url"] || "");
setApiKey((cur) => cur || settings["embeddings.api_key"] || "");
setModel((cur) => cur || settings["embeddings.model"] || "");
const p = settings["embeddings.provider"];
if (p === "openai" || p === "offline_hash") setProvider(p);
else setProvider("offline_hash");
}, [loading, settings]);
const run = async () => {
setLoading(true);
setLoading2(true);
setResult(null);
try {
const r = await AdminApi.testEmbeddings({
@@ -135,11 +194,11 @@ function EmbeddingsTestCard() {
provider,
});
setResult(r);
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed"));
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : extractErr(r));
} catch (err) {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err));
} finally {
setLoading(false);
setLoading2(false);
}
};
@@ -149,11 +208,26 @@ function EmbeddingsTestCard() {
<Input label={t("admin.api_url")} value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
<Input label={t("admin.api_key")} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} />
<Input label={t("admin.provider")} value={provider} onChange={(e) => setProvider(e.target.value)} placeholder="openai" />
<div className="w-full">
<label className="label" htmlFor="emb-test-provider">{t("admin.provider")}</label>
<select
id="emb-test-provider"
className="input"
value={provider}
onChange={(e) => setProvider(e.target.value as EmbeddingsProvider)}
>
{EMBEDDINGS_PROVIDERS.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<p className="mt-1 text-xs text-fg-muted">
If provider=openai and api_url is empty, the system falls back to llm.api_url
</p>
</div>
</div>
<div className="mt-3">
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
{loading ? t("admin.running") : t("admin.run_test")}
<Button onClick={run} loading={loading2}>
{loading2 ? t("admin.running") : t("admin.run_test")}
</Button>
</div>
{result && <TestResultCard result={result} />}
@@ -164,15 +238,26 @@ function EmbeddingsTestCard() {
function ProbeDimensionCard() {
const { t } = useTranslation();
const pushToast = useToastStore((s) => s.push);
const { loading, settings } = useSettingsDefaults();
const [apiUrl, setApiUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState("");
const [provider, setProvider] = useState("");
const [loading, setLoading] = useState(false);
const [provider, setProvider] = useState<EmbeddingsProvider>("offline_hash");
const [loading2, setLoading2] = useState(false);
const [result, setResult] = useState<EmbeddingsProbeResult | null>(null);
useEffect(() => {
if (loading) return;
setApiUrl((cur) => cur || settings["embeddings.api_url"] || "");
setApiKey((cur) => cur || settings["embeddings.api_key"] || "");
setModel((cur) => cur || settings["embeddings.model"] || "");
const p = settings["embeddings.provider"];
if (p === "openai" || p === "offline_hash") setProvider(p);
else setProvider("offline_hash");
}, [loading, settings]);
const run = async () => {
setLoading(true);
setLoading2(true);
setResult(null);
try {
const r = await AdminApi.probeDimension({
@@ -182,11 +267,11 @@ function ProbeDimensionCard() {
provider,
});
setResult(r);
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed"));
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : extractErr(r));
} catch (err) {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err));
} finally {
setLoading(false);
setLoading2(false);
}
};
@@ -196,11 +281,23 @@ function ProbeDimensionCard() {
<Input label={t("admin.api_url")} value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
<Input label={t("admin.api_key")} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} />
<Input label={t("admin.provider")} value={provider} onChange={(e) => setProvider(e.target.value)} />
<div className="w-full">
<label className="label" htmlFor="emb-probe-provider">{t("admin.provider")}</label>
<select
id="emb-probe-provider"
className="input"
value={provider}
onChange={(e) => setProvider(e.target.value as EmbeddingsProvider)}
>
{EMBEDDINGS_PROVIDERS.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
</div>
<div className="mt-3">
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
{loading ? t("admin.running") : t("admin.run_test")}
<Button onClick={run} loading={loading2}>
{loading2 ? t("admin.running") : t("admin.run_test")}
</Button>
</div>
{result && <TestResultCard result={result} />}
@@ -222,7 +319,7 @@ function RecreateCollectionsCard() {
setResult(r);
pushToast("success", `${t("admin.ok")}: dropped=${r.dropped} created=${r.created} dim=${r.dimension}`);
} catch (err) {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err));
} finally {
setLoading(false);
}
@@ -286,6 +383,11 @@ function TestResultCard({ result }: { result: Record<string, unknown> }) {
{result.error}
</pre>
)}
{result.error != null && typeof result.error === "object" && (
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-err">
{JSON.stringify(result.error, null, 2)}
</pre>
)}
{Array.isArray(result.first_5_values) && (
<p className="mt-1 text-fg-muted font-mono">
first_5_values: [{(result.first_5_values as number[]).slice(0, 5).map((v) => typeof v === "number" ? v.toFixed(4) : String(v)).join(", ")}]
@@ -299,3 +401,14 @@ function TestResultCard({ result }: { result: Record<string, unknown> }) {
</div>
);
}
/** Extract a human-readable error message from a test result object. */
function extractErr(result: Record<string, unknown>): string {
const e = result.error;
if (typeof e === "string") return e;
if (e && typeof e === "object") {
const obj = e as { message?: string; code?: string };
return obj.message || obj.code || JSON.stringify(e);
}
return "Failed";
}

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { useAuthStore } from "@/stores/authStore";
import { useUiStore } from "@/stores/uiStore";
import { useUiSettingsStore } from "@/stores/uiSettingsStore";
import { useToastStore } from "@/stores/toastStore";
import { Button } from "./Button";
@@ -18,6 +19,7 @@ export function Navbar() {
const language = useUiStore((s) => s.language);
const setLanguage = useUiStore((s) => s.setLanguage);
const pushToast = useToastStore((s) => s.push);
const logoUrl = useUiSettingsStore((s) => s.settings?.logo_url);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -49,7 +51,16 @@ export function Navbar() {
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4">
<div className="flex items-center gap-4">
<Link to={user ? "/worlds" : "/login"} className="flex items-center gap-2">
<span className="text-lg">🎲</span>
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoUrl}
alt={t("common.app_name")}
className="h-7 w-7 rounded object-contain"
/>
) : (
<span className="text-lg">🎲</span>
)}
<span className="font-semibold text-fg">{t("common.app_name")}</span>
</Link>
{user && (
@@ -57,9 +68,6 @@ export function Navbar() {
<NavLink to="/worlds" active={location.pathname === "/worlds" || location.pathname === "/"}>
{t("nav.worlds")}
</NavLink>
<NavLink to="/worlds/new" active={location.pathname === "/worlds/new"}>
{t("nav.create_world")}
</NavLink>
{user.is_admin && (
<NavLink to="/admin" active={location.pathname.startsWith("/admin")}>
{t("nav.admin")}

View File

@@ -246,9 +246,22 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
}
const res = await WorldsApi.create(payload);
const streamUrl = SessionsApi.builderStreamUrl(res.world_id);
let connectionLostToastShown = false;
const c = subscribeSse(streamUrl, {
onOpen: () => setState((s) => ({ ...s, sseStatus: "open" })),
onError: () => setState((s) => ({ ...s, sseStatus: "error" })),
onOpen: () => {
connectionLostToastShown = false;
setState((s) => ({ ...s, sseStatus: "open" }));
},
onError: () => {
setState((s) => ({ ...s, sseStatus: "error" }));
// The SSE client auto-reconnects with exponential backoff. Surface
// a single "Connection lost" toast so the user knows what's
// happening without spamming on every reconnect attempt.
if (!connectionLostToastShown) {
connectionLostToastShown = true;
pushToast("warning", t("sse.reconnecting"));
}
},
onClose: () => setState((s) => ({ ...s, sseStatus: "closed" })),
onEvent: handleEvent,
});

View File

@@ -1,12 +1,18 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { AdminApi, WorldsApi, toErrorMessage } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
import { useToastStore } from "@/stores/toastStore";
import type { WorldListItem, WorldStatus } from "@/types";
import { Button } from "@/components/ui/Button";
export interface WorldCardProps {
world: WorldListItem;
onDelete?: (world: WorldListItem) => void;
onRestored?: (world: WorldListItem) => void;
onPermanentlyDeleted?: (id: string) => void;
className?: string;
}
@@ -26,11 +32,47 @@ const STATUS_LABEL_KEY: Record<WorldStatus, string> = {
archived: "worlds.status_archived",
};
export function WorldCard({ world, onDelete, className }: WorldCardProps) {
export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, className }: WorldCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const pushToast = useToastStore((s) => s.push);
const [restoring, setRestoring] = useState(false);
const [hardDeleting, setHardDeleting] = useState(false);
const isReady = world.status === "ready";
const isArchived = world.status === "archived";
const isAdmin = !!user?.is_admin;
const handleRestore = async () => {
setRestoring(true);
try {
await WorldsApi.update(world.id, { status: "ready" });
pushToast("success", t("worlds.restored"));
onRestored?.({ ...world, status: "ready" });
} catch (err) {
pushToast("error", toErrorMessage(err, t("worlds.restore_failed")));
} finally {
setRestoring(false);
}
};
const handleHardDelete = async () => {
if (!isAdmin) return;
const confirmed = window.confirm(t("worlds.delete_permanent_confirm"));
if (!confirmed) return;
setHardDeleting(true);
try {
await AdminApi.hardDeleteWorld(world.id);
pushToast("success", t("worlds.deleted"));
onPermanentlyDeleted?.(world.id);
} catch (err) {
pushToast("error", toErrorMessage(err, t("worlds.delete_failed")));
} finally {
setHardDeleting(false);
}
};
return (
<article
@@ -68,32 +110,60 @@ export function WorldCard({ world, onDelete, className }: WorldCardProps) {
</div>
</dl>
<footer className="mt-2 flex gap-2">
<Button
size="sm"
variant={isReady ? "primary" : "secondary"}
onClick={() => navigate(`/worlds/${world.id}/play`)}
disabled={!isReady}
fullWidth
>
{t("worlds.play")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => navigate(`/worlds/${world.id}/edit`)}
>
{t("worlds.edit")}
</Button>
{onDelete && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(world)}
aria-label={t("common.delete")}
>
🗑
</Button>
<footer className="mt-2 flex flex-wrap gap-2">
{isArchived ? (
<>
<Button
size="sm"
variant="secondary"
onClick={handleRestore}
loading={restoring}
disabled={restoring}
fullWidth
>
{t("worlds.restore")}
</Button>
{isAdmin && (
<Button
size="sm"
variant="danger"
onClick={handleHardDelete}
loading={hardDeleting}
disabled={hardDeleting}
>
{t("worlds.delete_permanent")}
</Button>
)}
</>
) : (
<>
<Button
size="sm"
variant={isReady ? "primary" : "secondary"}
onClick={() => navigate(`/worlds/${world.id}/play`)}
disabled={!isReady}
fullWidth
>
{t("worlds.play")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => navigate(`/worlds/${world.id}/edit`)}
>
{t("worlds.edit")}
</Button>
{onDelete && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(world)}
aria-label={t("common.delete")}
>
🗑
</Button>
)}
</>
)}
</footer>
</article>

View File

@@ -146,9 +146,19 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
try {
await WorldsApi.edit(world.id, { instruction: text });
const url = SessionsApi.editorStreamUrl(world.id, text);
let connectionLostToastShown = false;
const c = subscribeSse(url, {
onOpen: () => setSseStatus("open"),
onError: () => setSseStatus("error"),
onOpen: () => {
connectionLostToastShown = false;
setSseStatus("open");
},
onError: () => {
setSseStatus("error");
if (!connectionLostToastShown) {
connectionLostToastShown = true;
pushToast("warning", t("sse.reconnecting"));
}
},
onClose: () => setSseStatus("closed"),
onEvent: handleEvent,
});

View File

@@ -64,7 +64,9 @@
"admin_register_success": "Admin account created. You can sign in now.",
"login_failed": "Sign-in failed",
"register_failed": "Registration failed",
"session_expired": "Session expired, please sign in again."
"session_expired": "Session expired, please sign in again.",
"username_hint": "Letters, numbers, and underscores only. No @ or other special characters.",
"username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed."
},
"worlds": {
"title": "Your Worlds",
@@ -85,7 +87,12 @@
"status_building": "Building",
"status_ready": "Ready",
"status_failed": "Failed",
"status_archived": "Archived"
"status_archived": "Archived",
"restore": "Restore",
"restored": "World restored.",
"restore_failed": "Failed to restore world.",
"delete_permanent": "Delete permanently",
"delete_permanent_confirm": "Permanently delete this world? This action cannot be undone and removes all steps, entities, and logs."
},
"builder": {
"title": "World Builder",
@@ -164,6 +171,9 @@
"rollback": "Rollback one step",
"rollback_confirm": "Rollback the last step?",
"rolled_back": "Rolled back one step.",
"not_ready": "World is not ready yet. Complete world creation first.",
"no_steps_to_retry": "No steps to retry yet.",
"no_steps_to_rollback": "No steps to rollback yet.",
"streaming": "AI is responding…",
"scene_chunk": "Scene",
"iteration_complete": "Step complete",
@@ -191,11 +201,12 @@
"settings_saved": "Settings saved.",
"settings_save_failed": "Failed to save settings.",
"settings_load_failed": "Failed to load settings.",
"group_llm": "LLM",
"group_llm": "LLM Configuration",
"group_embeddings": "Embeddings",
"group_qdrant": "Qdrant",
"group_ui": "UI",
"group_game": "Game",
"group_context": "Context Manager",
"group_ui": "UI Settings",
"group_game": "Game Settings",
"logs_filter_world": "World ID",
"logs_filter_stage": "Stage",
"logs_filter_status": "Status",
@@ -221,6 +232,9 @@
"users_deactivate": "Deactivate",
"stats_users": "Users",
"stats_worlds": "Worlds",
"stats_active_worlds": "Active Worlds",
"stats_archived": "Archived",
"stats_total_worlds": "Total Worlds",
"stats_steps": "Steps",
"stats_avg_latency": "Avg LLM latency",
"test_llm": "Test LLM",

View File

@@ -64,7 +64,9 @@
"admin_register_success": "Аккаунт администратора создан. Теперь можно войти.",
"login_failed": "Не удалось войти",
"register_failed": "Не удалось зарегистрироваться",
"session_expired": "Сессия истекла, пожалуйста, войдите снова."
"session_expired": "Сессия истекла, пожалуйста, войдите снова.",
"username_hint": "Только буквы, цифры и подчёркивание. Без @ и других спецсимволов.",
"username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются."
},
"worlds": {
"title": "Ваши миры",
@@ -85,7 +87,12 @@
"status_building": "Создаётся",
"status_ready": "Готов",
"status_failed": "Ошибка",
"status_archived": "В архиве"
"status_archived": "В архиве",
"restore": "Восстановить",
"restored": "Мир восстановлен.",
"restore_failed": "Не удалось восстановить мир.",
"delete_permanent": "Удалить навсегда",
"delete_permanent_confirm": "Навсегда удалить этот мир? Действие необратимо и удалит все шаги, сущности и логи."
},
"builder": {
"title": "Создание мира",
@@ -164,6 +171,9 @@
"rollback": "Откатить один шаг",
"rollback_confirm": "Откатить последний шаг?",
"rolled_back": "Шаг откатан.",
"not_ready": "Мир ещё не готов. Сначала завершите создание мира.",
"no_steps_to_retry": "Нет шагов для повтора.",
"no_steps_to_rollback": "Нет шагов для отката.",
"streaming": "AI отвечает…",
"scene_chunk": "Сцена",
"iteration_complete": "Шаг завершён",
@@ -191,11 +201,12 @@
"settings_saved": "Настройки сохранены.",
"settings_save_failed": "Не удалось сохранить настройки.",
"settings_load_failed": "Не удалось загрузить настройки.",
"group_llm": "LLM",
"group_llm": "Конфигурация LLM",
"group_embeddings": "Эмбеддинги",
"group_qdrant": "Qdrant",
"group_ui": "Интерфейс",
"group_game": "Игра",
"group_context": "Менеджер контекста",
"group_ui": "Настройки интерфейса",
"group_game": "Игровые настройки",
"logs_filter_world": "ID мира",
"logs_filter_stage": "Стадия",
"logs_filter_status": "Статус",
@@ -221,6 +232,9 @@
"users_deactivate": "Деактивировать",
"stats_users": "Пользователи",
"stats_worlds": "Миры",
"stats_active_worlds": "Активные миры",
"stats_archived": "В архиве",
"stats_total_worlds": "Всего миров",
"stats_steps": "Шаги",
"stats_avg_latency": "Средняя задержка LLM",
"test_llm": "Тест LLM",

View File

@@ -4,15 +4,41 @@
@layer base {
:root {
/* Dark theme (default) — RGB triplets so Tailwind opacity modifiers work. */
--bg: 15 17 21; /* #0f1115 */
--bg-soft: 23 26 33; /* #171a21 */
--bg-card: 30 35 44; /* #1e232c */
--fg: 230 233 239; /* #e6e9ef */
--fg-muted: 156 163 175; /* #9ca3af */
--fg-dim: 107 114 128; /* #6b7280 */
--accent: 139 92 246; /* #8b5cf6 */
--accent-hover: 124 58 237;/* #7c3aed */
--ok: 16 185 129; /* #10b981 */
--warn: 245 158 11; /* #f59e0b */
--err: 239 68 68; /* #ef4444 */
color-scheme: dark;
}
html {
@apply bg-bg text-fg antialiased;
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
}
html:not(.dark) {
/* Light theme — high-contrast, readable. */
--bg: 250 250 250; /* #fafafa */
--bg-soft: 241 241 244; /* #f1f1f4 */
--bg-card: 255 255 255; /* #ffffff */
--fg: 26 26 26; /* #1a1a1a */
--fg-muted: 75 85 99; /* #4b5563 */
--fg-dim: 107 114 128; /* #6b7280 */
--accent: 124 58 237; /* #7c3aed */
--accent-hover: 109 40 217;/* #6d28d9 */
--ok: 5 150 105; /* #059669 */
--warn: 217 119 6; /* #d97706 */
--err: 220 38 38; /* #dc2626 */
color-scheme: light;
}
html {
@apply antialiased;
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
}
body {
@apply min-h-screen bg-bg text-fg;
}
@@ -61,7 +87,7 @@
content: "▋";
margin-left: 1px;
animation: blink 1s steps(2) infinite;
color: var(--tw-prose-invert-colors, #8b5cf6);
color: rgb(var(--accent));
}
@keyframes blink {
@@ -73,14 +99,3 @@
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Light mode overrides */
html:not(.dark) body {
@apply bg-gray-100 text-gray-900;
}
html:not(.dark) .card {
@apply bg-white border-gray-200;
}
html:not(.dark) .input {
@apply bg-white border-gray-300 text-gray-900;
}

View File

@@ -18,6 +18,7 @@ import type {
LoginPayload,
Paginated,
PresetListItem,
PublicSettings,
RecreateCollectionsResult,
RegisterPayload,
RetryResponse,
@@ -47,6 +48,93 @@ export class ApiError extends Error {
}
}
/**
* Format a FastAPI/Starlette error `detail` field (which may be a string,
* an array of validation objects, or a nested object) into a single
* human-readable string. Avoids the dreaded "[object Object]" toast.
*/
export function formatApiErrorDetail(detail: unknown): string | null {
if (detail == null) return null;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
const parts: string[] = [];
for (const item of detail) {
if (typeof item === "string") {
parts.push(item);
continue;
}
if (item && typeof item === "object") {
const obj = item as {
msg?: string;
message?: string;
loc?: unknown;
type?: string;
ctx?: unknown;
};
const loc = Array.isArray(obj.loc)
? obj.loc.filter((x) => typeof x === "string" || typeof x === "number").join(".")
: obj.loc != null
? String(obj.loc)
: "";
const msg = obj.msg || obj.message || "";
const ctxStr =
obj.ctx && typeof obj.ctx === "object" ? JSON.stringify(obj.ctx) : "";
const text = [loc, msg, ctxStr].filter(Boolean).join(": ");
parts.push(text || JSON.stringify(item));
continue;
}
parts.push(JSON.stringify(item));
}
return parts.filter(Boolean).join("; ");
}
if (typeof detail === "object") {
const obj = detail as { message?: string; detail?: unknown; error?: string };
if (typeof obj.message === "string") return obj.message;
if (typeof obj.error === "string") return obj.error;
const nested = formatApiErrorDetail(obj.detail);
if (nested) return nested;
try {
return JSON.stringify(detail);
} catch {
return String(detail);
}
}
return null;
}
/**
* Convert any thrown value into a human-readable string suitable for display
* in toasts or inline error messages. Never returns "[object Object]".
*/
export function toErrorMessage(err: unknown, fallback = "Something went wrong"): string {
if (err == null) return fallback;
if (typeof err === "string") return err;
if (err instanceof ApiError) {
return err.message || fallback;
}
if (err instanceof Error) {
return err.message || fallback;
}
if (typeof err === "object") {
const obj = err as {
message?: unknown;
detail?: unknown;
error?: unknown;
status?: unknown;
};
const detail = formatApiErrorDetail(obj.detail);
if (detail) return detail;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.error === "string") return obj.error;
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}
return String(err);
}
export function getAccessToken(): string | null {
try {
return localStorage.getItem(ACCESS_TOKEN_KEY);
@@ -174,13 +262,13 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
if (res.status === 401) {
clearTokens();
unauthorizedHandler?.();
const data = (await parseResponse<{ detail?: string }>(res).catch(() => ({}))) as { detail?: string };
throw new ApiError(401, data?.detail || "Unauthorized");
const data = (await parseResponse<{ detail?: unknown }>(res).catch(() => ({}))) as { detail?: unknown };
throw new ApiError(401, formatApiErrorDetail(data?.detail) || "Unauthorized", data);
}
if (!res.ok) {
const data = (await parseResponse<{ detail?: string; message?: string }>(res).catch(() => ({}))) as { detail?: string; message?: string };
const message = data?.detail || data?.message || `Request failed (${res.status})`;
const data = (await parseResponse<{ detail?: unknown; message?: string }>(res).catch(() => ({}))) as { detail?: unknown; message?: string };
const message = formatApiErrorDetail(data?.detail) || data?.message || `Request failed (${res.status})`;
throw new ApiError(res.status, message, data);
}
@@ -267,7 +355,7 @@ export type LlmLogsQuery = {
export const AdminApi = {
settings: () => request<AdminSettingsResponse>("/admin/settings"),
updateSettings: (settings: Record<string, string>) =>
request<AdminSettingsResponse>("/admin/settings", { method: "PATCH", body: { settings } }),
request<{ updated: Record<string, string> }>("/admin/settings", { method: "PATCH", body: { settings } }),
llmLogs: (query: LlmLogsQuery = {}) =>
request<Paginated<LlmLog>>("/admin/llm-logs", { query: { ...query } }),
llmLog: (id: string) => request<LlmLogDetail>(`/admin/llm-logs/${id}`),
@@ -297,8 +385,11 @@ export const AdminApi = {
fd.append("kind", kind);
return request<UploadIconResult>("/admin/upload-icon", { method: "POST", formData: fd });
},
hardDeleteWorld: (id: string) =>
request<{ ok: boolean; deleted: string }>(`/admin/worlds/${id}`, { method: "DELETE" }),
};
export const MiscApi = {
health: () => request<HealthResponse>("/health"),
publicSettings: () => request<PublicSettings>("/settings/public"),
};

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/sessionStore";
import { useToastStore } from "@/stores/toastStore";
import { toErrorMessage } from "@/lib/api";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Spinner } from "@/components/ui/Spinner";
@@ -20,6 +21,7 @@ export function PlayPage() {
const world = useSessionStore((s) => s.world);
const environment = useSessionStore((s) => s.environment);
const nextActions = useSessionStore((s) => s.nextActions);
const recentSteps = useSessionStore((s) => s.recentSteps);
const loading = useSessionStore((s) => s.loading);
const error = useSessionStore((s) => s.error);
const submitting = useSessionStore((s) => s.submitting);
@@ -31,15 +33,26 @@ export function PlayPage() {
const reset = useSessionStore((s) => s.reset);
const [rollbackOpen, setRollbackOpen] = useState(false);
const [redirected, setRedirected] = useState(false);
useEffect(() => {
if (!id) return;
void fetchState(id).catch(() => pushToast("error", t("play.load_failed")));
void fetchState(id).catch((err) => pushToast("error", toErrorMessage(err, t("play.load_failed"))));
return () => {
reset();
};
}, [id, fetchState, reset, pushToast, t]);
// Redirect non-ready worlds to the edit page (once we have the world loaded).
useEffect(() => {
if (!id) return;
if (world && world.status !== "ready" && !redirected) {
pushToast("warning", t("play.not_ready"));
setRedirected(true);
navigate(`/worlds/${id}/edit`, { replace: true });
}
}, [id, world, redirected, navigate, pushToast, t]);
if (!id) return null;
if (loading && !world) {
@@ -60,25 +73,27 @@ export function PlayPage() {
);
}
if (!world) return null;
if (world.status !== "ready") return null;
const player = environment?.player;
const plotRails: PlotRail[] = Array.isArray(world.plot_rails) ? world.plot_rails : [];
const noSteps = recentSteps.length === 0;
const handleSend = (action: string) => {
void sendAction(id, action, "manual").catch((err) => {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err, "Failed"));
});
};
const handleSuggested = (action: string) => {
void sendAction(id, action, "suggested").catch((err) => {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err, "Failed"));
});
};
const handleRetry = () => {
void retry(id).catch((err) => {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err, "Failed"));
});
};
@@ -88,7 +103,7 @@ export function PlayPage() {
await rollback(id);
pushToast("success", t("play.rolled_back"));
} catch (err) {
pushToast("error", err instanceof Error ? err.message : "Failed");
pushToast("error", toErrorMessage(err, "Failed"));
}
};
@@ -160,14 +175,23 @@ export function PlayPage() {
<Card>
<div className="flex flex-col gap-2">
<Button size="sm" variant="secondary" onClick={handleRetry} disabled={submitting}>
<Button
size="sm"
variant="secondary"
onClick={handleRetry}
disabled={submitting || noSteps}
className={noSteps ? "opacity-50 cursor-not-allowed" : ""}
title={noSteps ? t("play.no_steps_to_retry") : undefined}
>
{t("play.retry_last")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setRollbackOpen(true)}
disabled={submitting}
disabled={submitting || noSteps}
className={noSteps ? "opacity-50 cursor-not-allowed" : ""}
title={noSteps ? t("play.no_steps_to_rollback") : undefined}
>
{t("play.rollback")}
</Button>

View File

@@ -3,11 +3,13 @@ import { useNavigate, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/authStore";
import { useToastStore } from "@/stores/toastStore";
import { ApiError } from "@/lib/api";
import { ApiError, toErrorMessage } from "@/lib/api";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card } from "@/components/ui/Card";
const USERNAME_INVALID_CHARS_RE = /[^a-zA-Z0-9_]/;
export function RegisterPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -25,6 +27,9 @@ export function RegisterPage() {
const next: Record<string, string> = {};
if (!email.includes("@")) next.email = t("errors.validation");
if (username.trim().length < 3) next.username = t("errors.validation");
if (USERNAME_INVALID_CHARS_RE.test(username.trim())) {
next.username = t("auth.username_invalid_chars");
}
if (password.length < 8) next.password = t("errors.validation");
if (password !== passwordConfirm) next.password_confirm = t("errors.validation");
setErrors(next);
@@ -45,8 +50,42 @@ export function RegisterPage() {
pushToast("success", t("auth.register_success"));
navigate("/login");
} catch (err) {
const msg = err instanceof ApiError ? err.message : t("auth.register_failed");
pushToast("error", msg);
if (err instanceof ApiError && err.status === 422) {
// FastAPI 422 validation error — extract field-level messages.
const detail = err.details;
const fieldMsgs: Record<string, string> = {};
let generalMsg = "";
if (Array.isArray(detail)) {
for (const item of detail) {
if (item && typeof item === "object") {
const obj = item as { loc?: unknown; msg?: string; message?: string };
const loc = Array.isArray(obj.loc)
? obj.loc.map((x) => String(x)).filter((x) => x !== "body" && x !== "query").join(".")
: obj.loc != null
? String(obj.loc)
: "";
const msg = obj.msg || obj.message || "";
if (loc) {
fieldMsgs[loc] = msg || t("errors.validation");
} else {
generalMsg = generalMsg ? `${generalMsg}; ${msg}` : msg;
}
}
}
}
// If we have specific field messages, show them inline.
if (Object.keys(fieldMsgs).length > 0) {
setErrors((prev) => ({ ...prev, ...fieldMsgs }));
}
// If the username field failed validation, show the specific message.
if (fieldMsgs.username || fieldMsgs["username"]) {
pushToast("error", t("auth.username_invalid_chars"));
} else {
pushToast("error", generalMsg || t("auth.register_failed"));
}
} else {
pushToast("error", toErrorMessage(err, t("auth.register_failed")));
}
} finally {
setSubmitting(false);
}
@@ -72,6 +111,7 @@ export function RegisterPage() {
autoComplete="username"
required
error={errors.username}
hint={t("auth.username_hint")}
/>
<Input
label={t("auth.password")}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useWorldsStore } from "@/stores/worldsStore";
@@ -27,6 +27,10 @@ export function WorldsListPage() {
void fetchList();
}, [fetchList]);
// Client-side filter: never show archived worlds (the backend already
// excludes them by default, but this is a safety net).
const visibleList = useMemo(() => list.filter((w) => w.status !== "archived"), [list]);
const confirmDelete = async () => {
if (!toDelete) return;
setDeleting(true);
@@ -48,7 +52,7 @@ export function WorldsListPage() {
<Button onClick={() => navigate("/worlds/new")}>{t("worlds.create_new")}</Button>
</header>
{loading && list.length === 0 ? (
{loading && visibleList.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-fg-muted">
<Spinner size="sm" /> {t("common.loading")}
</div>
@@ -59,7 +63,7 @@ export function WorldsListPage() {
{t("common.retry")}
</Button>
</Card>
) : list.length === 0 ? (
) : visibleList.length === 0 ? (
<Card>
<p className="text-sm text-fg-muted">{t("worlds.empty")}</p>
<Button className="mt-3" onClick={() => navigate("/worlds/new")}>
@@ -68,7 +72,7 @@ export function WorldsListPage() {
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{list.map((w) => (
{visibleList.map((w) => (
<WorldCard key={w.id} world={w} onDelete={setToDelete} />
))}
</div>

View File

@@ -1,6 +1,7 @@
import { create } from "zustand";
import { SessionsApi } from "@/lib/api";
import { SessionsApi, toErrorMessage } from "@/lib/api";
import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse";
import { useToastStore } from "@/stores/toastStore";
import type {
Environment,
SessionState,
@@ -83,8 +84,11 @@ function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string)
break;
case "error": {
const data = event.data as { message?: string; code?: string };
pushMessage({ id: uid(), kind: "error", message: data?.message || "Stream error" });
const msg = typeof data?.message === "string" ? data.message : "Stream error";
pushMessage({ id: uid(), kind: "error", message: msg });
patch({ sseStatus: "error" });
// Surface the error as a toast so it's never silently swallowed.
useToastStore.getState().push("error", msg);
break;
}
case "warning": {
@@ -226,7 +230,7 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
} catch (err) {
set({
loading: false,
error: err instanceof Error ? err.message : "Failed to load session",
error: toErrorMessage(err, "Failed to load session"),
});
}
},
@@ -238,10 +242,9 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
get().subscribeIterate(worldId, res.step_id);
} catch (err) {
set({
submitting: false,
error: err instanceof Error ? err.message : "Failed to send action",
});
const msg = toErrorMessage(err, "Failed to send action");
set({ submitting: false, error: msg });
useToastStore.getState().push("error", msg);
throw err;
}
},
@@ -253,10 +256,9 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
get().subscribeIterate(worldId, res.step_id);
} catch (err) {
set({
submitting: false,
error: err instanceof Error ? err.message : "Failed to retry",
});
const msg = toErrorMessage(err, "Failed to retry");
set({ submitting: false, error: msg });
useToastStore.getState().push("error", msg);
throw err;
}
},
@@ -272,9 +274,21 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
existing.close();
}
const url = SessionsApi.iterateStreamUrl(worldId, stepId);
let erroredOnce = false;
const controller = subscribeSse(url, {
onOpen: () => set({ sseStatus: "open" }),
onError: () => set({ sseStatus: "error" }),
onOpen: () => {
erroredOnce = false;
set({ sseStatus: "open" });
},
onError: () => {
set({ sseStatus: "error" });
// Avoid toasting on every reconnect attempt — only toast the first
// transition into the error state.
if (!erroredOnce) {
erroredOnce = true;
useToastStore.getState().push("warning", "Connection lost. Retrying…");
}
},
onClose: () => set({ sseStatus: "closed" }),
onEvent: (event) => handleEvent(get(), event, worldId),
});

View File

@@ -0,0 +1,72 @@
import { create } from "zustand";
import { MiscApi } from "@/lib/api";
import type { PublicSettings } from "@/types";
interface UiSettingsState {
settings: PublicSettings | null;
loaded: boolean;
fetch: () => Promise<void>;
apply: (s: PublicSettings) => void;
}
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
page_title: "AI-RPG",
favicon_url: "/icon.png",
logo_url: "/icon.png",
og_image_url: "",
};
function applyToDocument(s: PublicSettings): void {
if (s.page_title) document.title = s.page_title;
if (s.favicon_url) {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
link.href = s.favicon_url;
}
if (s.og_image_url) {
let meta = document.querySelector<HTMLMetaElement>('meta[property="og:image"]');
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("property", "og:image");
document.head.appendChild(meta);
}
meta.content = s.og_image_url;
}
}
export const useUiSettingsStore = create<UiSettingsState>((set, get) => ({
settings: null,
loaded: false,
fetch: async () => {
try {
const s = await MiscApi.publicSettings();
applyToDocument(s);
set({ settings: s, loaded: true });
} catch {
// Network or 404 — fall back to defaults but still mark as loaded so
// the app can render.
const fallback = DEFAULT_PUBLIC_SETTINGS;
applyToDocument(fallback);
set({ settings: fallback, loaded: true });
}
},
apply: (s) => {
applyToDocument(s);
set({ settings: s, loaded: true });
},
}));
/**
* Re-fetch public UI settings. Intended to be called after admin saves
* UI-related settings (page_title, favicon_url, logo_url, og_image_url).
*/
export function refreshUiSettings(): Promise<void> {
return useUiSettingsStore.getState().fetch();
}
// Kick off an initial fetch on module load (non-blocking, non-fatal).
void useUiSettingsStore.getState().fetch();

View File

@@ -237,31 +237,55 @@ export interface AdminSettingsResponse {
export interface LlmLog {
id: string;
world_id: string | null;
world_id?: string | null;
stage: string;
status: string;
model: string;
latency_ms: number | null;
tokens: number | null;
prompt: string | null;
response: string | null;
error: string | null;
model: string | null;
prompt_tokens: number | null;
completion_tokens: number | null;
error_message?: string | null;
created_at: string;
[key: string]: unknown;
}
export interface LlmLogDetail extends LlmLog {
messages?: unknown;
export interface LlmLogDetail {
id: string;
user_id?: string | null;
world_id?: string | null;
step_id?: string | null;
stage: string;
model: string;
status: string;
request_messages: unknown;
request_tools?: unknown;
response_message: unknown;
tool_calls?: unknown;
prompt_tokens: number | null;
completion_tokens: number | null;
latency_ms: number | null;
temperature: number | null;
error_message: string | null;
created_at: string;
[key: string]: unknown;
}
export interface AdminStats {
users: number;
worlds: number;
worlds_total?: number;
worlds_archived?: number;
steps: number;
avg_llm_latency_ms: number | null;
}
export interface PublicSettings {
page_title: string;
favicon_url: string;
logo_url: string;
og_image_url: string;
}
export interface LlmTestResult {
ok: boolean;
response?: string;

View File

@@ -9,22 +9,22 @@ export default {
extend: {
colors: {
bg: {
DEFAULT: "#0f1115",
soft: "#171a21",
card: "#1e232c",
DEFAULT: "rgb(var(--bg) / <alpha-value>)",
soft: "rgb(var(--bg-soft) / <alpha-value>)",
card: "rgb(var(--bg-card) / <alpha-value>)",
},
fg: {
DEFAULT: "#e6e9ef",
muted: "#9ca3af",
dim: "#6b7280",
DEFAULT: "rgb(var(--fg) / <alpha-value>)",
muted: "rgb(var(--fg-muted) / <alpha-value>)",
dim: "rgb(var(--fg-dim) / <alpha-value>)",
},
accent: {
DEFAULT: "#8b5cf6",
hover: "#7c3aed",
DEFAULT: "rgb(var(--accent) / <alpha-value>)",
hover: "rgb(var(--accent-hover) / <alpha-value>)",
},
ok: "#10b981",
warn: "#f59e0b",
err: "#ef4444",
ok: "rgb(var(--ok) / <alpha-value>)",
warn: "rgb(var(--warn) / <alpha-value>)",
err: "rgb(var(--err) / <alpha-value>)",
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],

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/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/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/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"}