This commit is contained in:
Mikan
2026-06-21 07:52:25 +03:00
parent e98559a587
commit 7cbe8da103
25 changed files with 1091 additions and 284 deletions

View File

@@ -164,6 +164,14 @@ export default function App() {
/>
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<Navigate to="/admin/stats" replace />
</ProtectedRoute>
}
/>
<Route
path="/admin/:tab"
element={
<ProtectedRoute requireAdmin>
<Layout>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminApi, toErrorMessage } from "@/lib/api";
import { useToastStore } from "@/stores/toastStore";
@@ -30,6 +30,9 @@ const STAGE_OPTIONS = [
const STATUS_OPTIONS = ["", "ok", "timeout", "api_error", "parse_error", "validation_error"];
/** Auto-refresh interval (ms). */
const AUTO_REFRESH_INTERVAL_MS = 5000;
function statusColor(status: string): string {
if (status === "ok") return "bg-ok/15 text-ok";
const errKinds = ["timeout", "api_error", "parse_error", "validation_error", "error"];
@@ -51,6 +54,14 @@ export function LlmLogsTable() {
const [detailLoading, setDetailLoading] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
// Auto-refresh state.
const [autoRefresh, setAutoRefresh] = useState(true);
const [newLogsCount, setNewLogsCount] = useState(0);
// True while any filter input/select has focus — we pause polling then so
// we don't yank the table out from under the user.
const filterFocusRef = useRef(false);
const [, forceRerender] = useState(0);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
@@ -62,6 +73,8 @@ export function LlmLogsTable() {
per_page: perPage,
});
setData(res);
// Reset the "new logs" counter when we explicitly (re)fetch.
setNewLogsCount(0);
} catch (err) {
pushToast("error", toErrorMessage(err));
} finally {
@@ -73,6 +86,40 @@ export function LlmLogsTable() {
void fetchLogs();
}, [fetchLogs]);
// Silent auto-refresh polling. Only when:
// - autoRefresh is enabled
// - user is on the first page
// - no filter input is currently focused
useEffect(() => {
if (!autoRefresh) return;
const interval = window.setInterval(async () => {
if (page !== 1) return;
if (filterFocusRef.current) return;
try {
const res = await AdminApi.llmLogs({
world_id: appliedFilters.world_id || undefined,
stage: appliedFilters.stage || undefined,
status_filter: appliedFilters.status_filter || undefined,
page: 1,
per_page: perPage,
});
setData((prev) => {
if (!prev) return res;
// Detect new items by comparing top-of-list ids.
const prevIds = new Set(prev.items.map((l) => l.id));
const newOnes = res.items.filter((l) => !prevIds.has(l.id));
if (newOnes.length > 0) {
setNewLogsCount((n) => n + newOnes.length);
}
return res;
});
} catch {
// Silent — don't spam toasts on auto-refresh errors.
}
}, AUTO_REFRESH_INTERVAL_MS);
return () => window.clearInterval(interval);
}, [autoRefresh, page, appliedFilters, perPage]);
const applyFilters = () => {
setAppliedFilters(filters);
setPage(1);
@@ -92,15 +139,50 @@ export function LlmLogsTable() {
}
};
// Filter input focus tracking — we use a wrapping <div> with onFocus /
// onBlur (capture phase) so any input/select inside counts.
const handleFilterFocus = () => {
filterFocusRef.current = true;
forceRerender((n) => n + 1);
};
const handleFilterBlur = () => {
filterFocusRef.current = false;
forceRerender((n) => n + 1);
};
// Short preview of the world_id filter value, for the column header.
const worldFilterPreview = appliedFilters.world_id
? appliedFilters.world_id.slice(0, 8)
: "";
const autoRefreshActive = autoRefresh && page === 1 && !filterFocusRef.current;
return (
<div className="space-y-4">
<Card title={t("admin.tab_logs")}>
<div className="grid gap-2 sm:grid-cols-4">
<Card
title={t("admin.tab_logs")}
actions={
<div className="flex items-center gap-2">
{newLogsCount > 0 && (
<span className="badge bg-accent/15 text-accent">
{t("admin.logs_new_count", { count: newLogsCount })}
</span>
)}
<Button
size="sm"
variant={autoRefresh ? "secondary" : "ghost"}
onClick={() => setAutoRefresh((v) => !v)}
title={t("admin.logs_auto_refresh_tip")}
>
{autoRefresh ? "⏸ " + t("admin.logs_pause") : "▶ " + t("admin.logs_resume")}
</Button>
</div>
}
>
<div
className="grid gap-2 sm:grid-cols-4"
onFocus={handleFilterFocus}
onBlur={handleFilterBlur}
>
<Input
label={t("admin.logs_filter_world")}
value={filters.world_id}
@@ -139,6 +221,11 @@ export function LlmLogsTable() {
</Button>
</div>
</div>
<p className="mt-2 text-xs text-fg-muted">
{autoRefreshActive
? t("admin.logs_auto_refresh_on")
: t("admin.logs_auto_refresh_off")}
</p>
</Card>
<Card>

View File

@@ -86,19 +86,20 @@ function fieldType(key: string): FieldType {
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;
}
/**
* Returns the localized description for a setting key. The key format is
* `admin.setting_desc.{setting_key}`. Falls back to the backend-provided
* description, then to undefined.
*/
function useSettingDesc(): (key: string, fallback?: string) => string | undefined {
const { t } = useTranslation();
return (key: string, fallback?: string) => {
const tKey = `admin.setting_desc.${key}`;
const translated = t(tKey);
// i18next returns the key itself when no translation exists.
if (translated === tKey) return fallback;
return translated;
};
}
function castValue(key: string, raw: string): string {
@@ -120,6 +121,22 @@ export function SettingsPanel() {
const [data, setData] = useState<AdminSettingsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [draft, setDraft] = useState<Record<string, string>>({});
// Collapsed state: Set of group ids that are collapsed. Default: ALL
// groups collapsed (the user clicks to expand the one they want to edit).
const allGroupIds = useMemo(() => GROUPS.map((g) => g.id).concat(["text_replacements"]), []);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(allGroupIds));
const toggleCollapsed = (id: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const expandAll = () => setCollapsed(new Set());
const collapseAll = () => setCollapsed(new Set(allGroupIds));
useEffect(() => {
let cancelled = false;
@@ -174,7 +191,7 @@ export function SettingsPanel() {
}
}
if (Object.keys(diff).length === 0) {
pushToast("info", "No changes to save.");
pushToast("info", t("admin.no_changes"));
return;
}
try {
@@ -216,7 +233,7 @@ export function SettingsPanel() {
if (!data) return;
const before = data.settings[key] ?? "";
if (before === value) {
pushToast("info", "No changes to save.");
pushToast("info", t("admin.no_changes"));
return;
}
try {
@@ -260,11 +277,21 @@ export function SettingsPanel() {
tabIndex={-1}
readOnly
/>
<div>
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
<p className="mt-1 text-xs text-fg-muted">
Each card saves independently. Secret values (api_key) are masked after save.
</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
<p className="mt-1 text-xs text-fg-muted">
{t("admin.settings_hint")}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={expandAll}>
{t("admin.expand_all")}
</Button>
<Button size="sm" variant="secondary" onClick={collapseAll}>
{t("admin.collapse_all")}
</Button>
</div>
</div>
{GROUPS.map((g) => {
const entries = grouped[g.id];
@@ -273,6 +300,7 @@ export function SettingsPanel() {
// fetcher, text replacements) live here. For other groups, skip
// when empty.
if ((!entries || entries.length === 0) && g.id !== "llm") return null;
const isCollapsed = collapsed.has(g.id);
return (
<Fragment key={g.id}>
<SettingsGroupCard
@@ -280,6 +308,8 @@ export function SettingsPanel() {
title={t(g.labelKey)}
entries={entries || []}
draft={draft}
collapsed={isCollapsed}
onToggle={() => toggleCollapsed(g.id)}
onChange={(key, value) =>
setDraft((d) => ({ ...d, [key]: value }))
}
@@ -289,6 +319,8 @@ export function SettingsPanel() {
<TextReplacementsCard
rawValue={draft["llm.text_replacements"] ?? ""}
onSave={(v) => void handleSaveKey("llm.text_replacements", v)}
collapsed={collapsed.has("text_replacements")}
onToggle={() => toggleCollapsed("text_replacements")}
/>
)}
</Fragment>
@@ -303,11 +335,22 @@ interface SettingsGroupCardProps {
title: string;
entries: Array<{ key: string; description?: string }>;
draft: Record<string, string>;
collapsed: boolean;
onToggle: () => void;
onChange: (key: string, value: string) => void;
onSave: () => void;
}
function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
function SettingsGroupCard({
groupId,
title,
entries,
draft,
collapsed,
onToggle,
onChange,
onSave,
}: SettingsGroupCardProps) {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const handleSave = async () => {
@@ -331,37 +374,53 @@ function SettingsGroupCard({ groupId, title, entries, draft, onChange, onSave }:
void groupId; // groupId currently used only for the model-entry lookup above
return (
<Card
title={title}
title={
<button
type="button"
onClick={onToggle}
className="flex items-center gap-2 text-left"
aria-expanded={!collapsed}
>
<span className="text-xs text-fg-muted w-3 inline-block">
{collapsed ? "▶" : "▼"}
</span>
<span>{title}</span>
</button>
}
actions={
<Button size="sm" onClick={handleSave} loading={saving}>
{t("common.save")}
</Button>
!collapsed && (
<Button size="sm" onClick={handleSave} loading={saving}>
{t("common.save")}
</Button>
)
}
>
<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>
{!collapsed && (
<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>
);
}
@@ -376,23 +435,30 @@ interface SettingFieldProps {
function SettingField({ settingKey, description, value, onChange }: SettingFieldProps) {
const { t } = useTranslation();
const ft = fieldType(settingKey);
const hint = hintFor(settingKey) || description;
const localizedDesc = useSettingDesc();
const hint = localizedDesc(settingKey, description);
const label = settingKey;
if (ft === "boolean") {
// Render booleans as a checkbox (with the key as the label) rather than
// a dropdown — it's a more natural control for a true/false toggle.
const checked = value === "true";
return (
<div className="w-full">
<label className="label" htmlFor={`setting-${settingKey}`}>{label}</label>
<select
id={`setting-${settingKey}`}
className="input"
autoComplete="off"
value={value === "true" ? "true" : value === "false" ? "false" : value}
onChange={(e) => onChange(e.target.value)}
<label
htmlFor={`setting-${settingKey}`}
className="flex items-center gap-2 text-sm text-fg cursor-pointer"
>
<option value="true">true</option>
<option value="false">false</option>
</select>
<input
id={`setting-${settingKey}`}
type="checkbox"
autoComplete="off"
checked={checked}
onChange={(e) => onChange(e.target.checked ? "true" : "false")}
className="h-4 w-4"
/>
<span className="font-mono text-xs">{label}</span>
</label>
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
</div>
);
@@ -503,10 +569,11 @@ interface LlmModelFieldProps {
*/
function LlmModelField({ value, onChange, apiUrl, apiKey, description }: LlmModelFieldProps) {
const { t } = useTranslation();
const localizedDesc = useSettingDesc();
const [fetching, setFetching] = useState(false);
const [models, setModels] = useState<string[] | null>(null);
const [fetchError, setFetchError] = useState(false);
const hint = description;
const hint = localizedDesc("llm.model", description);
const handleFetch = async () => {
setFetching(true);
@@ -604,6 +671,8 @@ interface TextReplacementRule {
interface TextReplacementsCardProps {
rawValue: string;
onSave: (serializedJson: string) => void;
collapsed: boolean;
onToggle: () => void;
}
/** Parse the persisted JSON string into a list of rules. Tolerates
@@ -634,7 +703,7 @@ function serializeReplacements(rules: TextReplacementRule[]): string {
return JSON.stringify(rules.map((r) => ({ from: r.from, to: r.to })));
}
function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
function TextReplacementsCard({ rawValue, onSave, collapsed, onToggle }: 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.
@@ -673,52 +742,68 @@ function TextReplacementsCard({ rawValue, onSave }: TextReplacementsCardProps) {
return (
<Card
title={t("admin.text_replacements_title")}
description={t("admin.text_replacements_help")}
title={
<button
type="button"
onClick={onToggle}
className="flex items-center gap-2 text-left"
aria-expanded={!collapsed}
>
<span className="text-xs text-fg-muted w-3 inline-block">
{collapsed ? "▶" : "▼"}
</span>
<span>{t("admin.text_replacements_title")}</span>
</button>
}
description={!collapsed ? t("admin.text_replacements_help") : undefined}
actions={
<Button size="sm" onClick={handleSave} loading={saving} disabled={!dirty}>
{t("common.save")}
</Button>
!collapsed && (
<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>
{!collapsed && (
<div className="space-y-2">
{rules.length === 0 && (
<p className="text-xs text-fg-muted">{t("admin.text_replacements_empty")}</p>
)}
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
<input
type="text"
className="input flex-1"
placeholder={t("admin.text_replacements_from")}
value={rule.from}
onChange={(e) => updateRule(idx, "from", e.target.value)}
autoComplete="off"
/>
<span className="text-xs text-fg-muted"></span>
<input
type="text"
className="input flex-1"
placeholder={t("admin.text_replacements_to")}
value={rule.to}
onChange={(e) => updateRule(idx, "to", e.target.value)}
autoComplete="off"
/>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => removeRule(idx)}
aria-label={t("common.delete")}
>
</Button>
</div>
))}
<Button type="button" size="sm" variant="secondary" onClick={addRule}>
+ {t("admin.text_replacements_add")}
</Button>
</div>
)}
</Card>
);
}

View File

@@ -221,7 +221,7 @@ function EmbeddingsTestCard() {
))}
</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
{t("admin.test_provider_hint")}
</p>
</div>
</div>
@@ -328,7 +328,7 @@ function RecreateCollectionsCard() {
return (
<Card title={t("admin.recreate_collections")}>
<p className="text-xs text-fg-muted mb-3">
Drops and recreates Qdrant collections based on current embeddings dimension.
{t("admin.recreate_collections_hint")}
</p>
<Button onClick={run} loading={loading} variant="danger">
{loading ? t("admin.running") : t("admin.recreate_collections")}
@@ -336,10 +336,10 @@ function RecreateCollectionsCard() {
{result && (
<div className="mt-3 text-sm">
<p>
<span className="text-fg-muted">Dropped:</span> {result.dropped}
<span className="text-fg-muted">{t("admin.recreate_dropped")}:</span> {result.dropped}
</p>
<p>
<span className="text-fg-muted">Created:</span> {result.created}
<span className="text-fg-muted">{t("admin.recreate_created")}:</span> {result.created}
</p>
<p>
<span className="text-fg-muted">{t("admin.dimension")}:</span> {result.dimension}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { cn } from "@/lib/cn";
import { useSessionStore } from "@/stores/sessionStore";
import type { Step } from "@/types";
@@ -11,23 +12,49 @@ export interface ChatViewProps {
export function ChatView({ className }: ChatViewProps) {
const { t } = useTranslation();
const world = useSessionStore((s) => s.world);
const recentSteps = useSessionStore((s) => s.recentSteps);
const streamMessages = useSessionStore((s) => s.streamMessages);
const streamingText = useSessionStore((s) => s.streamingText);
const submitting = useSessionStore((s) => s.submitting);
const error = useSessionStore((s) => s.error);
const empty = recentSteps.length === 0 && !submitting && streamMessages.length === 0;
const introScene = world?.intro_scene || null;
const hasIntro = Boolean(introScene);
const noSteps = recentSteps.length === 0;
// Empty state: no intro scene AND no steps AND nothing streaming.
const empty = noSteps && !submitting && streamMessages.length === 0 && !hasIntro;
// Show intro scene as the first chat message when there are no steps yet
// (and we're not currently streaming a new response that would replace it).
const showIntro = hasIntro && noSteps && !streamingText;
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [recentSteps, streamMessages, streamingText, submitting]);
}, [recentSteps, streamMessages, streamingText, submitting, showIntro]);
return (
<div className={cn("flex flex-col gap-3 overflow-y-auto p-3", className)}>
{empty && (
<div className="m-auto text-center text-sm text-fg-muted py-8">
<p>{t("play.no_actions_yet")}</p>
<div className="m-auto max-w-md text-center text-sm text-fg-muted py-8 space-y-2">
<p>{t("play.no_intro_scene")}</p>
{world && (
<Link
to={`/worlds/${world.id}/edit`}
className="inline-block rounded-md border border-accent/40 px-3 py-1.5 text-accent hover:bg-accent/10"
>
{t("play.go_to_edit")}
</Link>
)}
</div>
)}
{showIntro && introScene && (
<div className="max-w-[90%] rounded-lg bg-bg-card px-3 py-2">
<p className="text-xs font-semibold text-fg-muted mb-0.5">
{t("play.game_master")}
</p>
<p className="whitespace-pre-wrap text-sm text-fg">{introScene}</p>
</div>
)}

View File

@@ -4,7 +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 { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
import { useToastStore } from "@/stores/toastStore";
import { Button } from "./Button";
@@ -19,7 +19,9 @@ 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 settings = useUiSettingsStore((s) => s.settings);
const logoUrl = settings?.logo_url;
const headerTitle = selectHeaderTitle(settings);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -55,13 +57,13 @@ export function Navbar() {
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoUrl}
alt={t("common.app_name")}
alt={headerTitle}
className="h-7 w-7 rounded object-contain"
/>
) : (
<span className="text-lg">🎲</span>
)}
<span className="font-semibold text-fg">{t("common.app_name")}</span>
<span className="font-semibold text-fg">{headerTitle}</span>
</Link>
{user && (
<div className="hidden md:flex items-center gap-1 ml-4">

View File

@@ -2,14 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { WorldsApi, PresetsApi, SessionsApi } from "@/lib/api";
import { WorldsApi, PresetsApi, SessionsApi, MiscApi, toErrorMessage } from "@/lib/api";
import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse";
import { useToastStore } from "@/stores/toastStore";
import type {
CreateWorldPayload,
Language,
PresetListItem,
World,
} from "@/types";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
@@ -41,13 +40,6 @@ interface BuilderState {
sseStatus: "idle" | "connecting" | "open" | "error" | "closed";
}
const DEFAULT_FORM_DATA: Record<string, unknown> = {
setting: "fantasy",
tone: "epic",
starting_level: 1,
notes: "",
};
export interface WorldBuilderProps {
className?: string;
}
@@ -62,14 +54,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
const [presetsLoading, setPresetsLoading] = useState(false);
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
const [name, setName] = useState("");
// Form fields
const [setting, setSetting] = useState("");
const [name, setName] = useState<string>(() => t("worlds.default_name"));
const [language, setLanguage] = useState<Language>("en");
const [playerName, setPlayerName] = useState("");
const [notes, setNotes] = useState("");
const [formData, setFormData] = useState<string>(() =>
JSON.stringify(DEFAULT_FORM_DATA, null, 2),
);
const [formError, setFormError] = useState<string | null>(null);
const [nameFetching, setNameFetching] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [state, setState] = useState<BuilderState>({
@@ -84,6 +75,18 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
// SSE onEvent handler captured at subscription time) without forcing a
// re-subscribe on every controller change.
const controllerRef = useRef<SseController | null>(null);
// Created world id — saved on submit so we can redirect to its edit page
// when the builder stream completes.
const createdWorldIdRef = useRef<string | null>(null);
// When the user changes language, refresh the default world name (only if
// the user hasn't customized it). This keeps the placeholder in sync.
const lastDefaultNameRef = useRef<string>(t("worlds.default_name"));
useEffect(() => {
const def = t("worlds.default_name");
setName((cur) => (cur === lastDefaultNameRef.current ? def : cur));
lastDefaultNameRef.current = def;
}, [t]);
useEffect(() => {
let cancelled = false;
@@ -92,7 +95,12 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
.then((res) => {
if (cancelled) return;
setPresets(res.items);
if (res.items.length > 0) setSelectedPresetId(res.items[0].id);
if (res.items.length > 0) {
setSelectedPresetId(res.items[0].id);
// Default world name for preset mode = preset name.
setName(res.items[0].name);
lastDefaultNameRef.current = res.items[0].name;
}
})
.catch(() => {
if (cancelled) return;
@@ -110,21 +118,51 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
};
}, [controller]);
// When mode switches, reset the default world name appropriately.
const handleModeChange = (next: Mode) => {
if (next === mode) return;
setMode(next);
if (next === "preset") {
const p = presets.find((x) => x.id === selectedPresetId);
if (p) {
setName(p.name);
lastDefaultNameRef.current = p.name;
}
} else {
const def = t("worlds.default_name");
setName(def);
lastDefaultNameRef.current = def;
}
};
// When user picks a different preset, update the default world name to
// match the preset's name (unless they've already customized it).
const handlePresetChange = (id: string) => {
setSelectedPresetId(id);
const p = presets.find((x) => x.id === id);
if (p) {
setName(p.name);
lastDefaultNameRef.current = p.name;
}
};
const canSubmit = useMemo(() => {
if (submitting || state.phase === "building") return false;
if (!name.trim() || !playerName.trim()) return false;
if (mode === "preset" && !selectedPresetId) return false;
if (mode === "form" && formError) return false;
if (mode === "form" && !setting.trim()) return false;
return true;
}, [submitting, state.phase, name, playerName, mode, selectedPresetId, formError]);
}, [submitting, state.phase, name, playerName, mode, selectedPresetId, setting]);
const onFormChange = (v: string) => {
setFormData(v);
const handleRandomName = async () => {
setNameFetching(true);
try {
JSON.parse(v);
setFormError(null);
const res = await MiscApi.randomName(language);
if (res?.name) setPlayerName(res.name);
} catch (err) {
setFormError(err instanceof Error ? err.message : "Invalid JSON");
pushToast("error", toErrorMessage(err, t("builder.name_random_failed")));
} finally {
setNameFetching(false);
}
};
@@ -259,10 +297,15 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
notes: notes.trim() || undefined,
};
} else {
const parsedForm = JSON.parse(formData);
// Backend still expects `form_data` as a free-form object for
// mode=form. We collect our structured fields into that object so
// the user no longer has to edit raw JSON.
payload = {
mode: "form",
form_data: parsedForm,
form_data: {
setting: setting.trim(),
notes: notes.trim() || undefined,
},
name: name.trim(),
language,
player_name: playerName.trim(),
@@ -295,7 +338,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
// Save world id for redirect on done
createdWorldIdRef.current = res.world_id;
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create world";
const message = toErrorMessage(err, t("builder.build_failed"));
pushToast("error", message);
setState((s) => ({ ...s, phase: "error" }));
} finally {
@@ -303,16 +346,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
}
};
const createdWorldIdRef = useMemoRef<string | null>(null);
// On done, navigate to play page
// On done, navigate to the EDIT page (not play) — the user should
// review the world, generate the intro scene, then click Play when ready.
useEffect(() => {
if (state.phase === "done" && createdWorldIdRef.current) {
const id = createdWorldIdRef.current;
const timer = window.setTimeout(() => {
void WorldsApi.get(id).then((w: World) => {
if (w.status === "ready") navigate(`/worlds/${id}/play`);
});
navigate(`/worlds/${id}/edit`);
}, 800);
return () => window.clearTimeout(timer);
}
@@ -363,13 +403,13 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
active={mode === "preset"}
title={t("builder.mode_preset")}
description={t("builder.preset_help")}
onClick={() => setMode("preset")}
onClick={() => handleModeChange("preset")}
/>
<ModeButton
active={mode === "form"}
title={t("builder.mode_form")}
description={t("builder.form_help")}
onClick={() => setMode("form")}
onClick={() => handleModeChange("form")}
/>
</div>
</Card>
@@ -392,7 +432,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
) : (
<select
value={selectedPresetId}
onChange={(e) => setSelectedPresetId(e.target.value)}
onChange={(e) => handlePresetChange(e.target.value)}
className="input"
>
{presets.map((p) => (
@@ -406,32 +446,49 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
)}
{mode === "form" && (
<div>
<label className="label">{t("builder.step_form")}</label>
<Textarea
value={formData}
onChange={(e) => onFormChange(e.target.value)}
rows={10}
className="font-mono text-xs"
error={formError ?? undefined}
/>
</div>
<Textarea
label={t("builder.setting")}
hint={t("builder.setting_help")}
value={setting}
onChange={(e) => setSetting(e.target.value)}
rows={4}
placeholder={t("builder.setting_placeholder")}
/>
)}
<Input
label={t("builder.world_name")}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="The Forgotten Realm"
placeholder={t("worlds.default_name")}
/>
<div className="grid grid-cols-2 gap-3">
<Input
label={t("builder.player_name")}
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Aria"
/>
<div>
<label className="label">{t("builder.player_name")}</label>
<div className="flex gap-2">
<input
className="input flex-1"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Aria"
autoComplete="off"
/>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void handleRandomName()}
loading={nameFetching}
disabled={nameFetching}
title={t("builder.name_random_tip")}
aria-label={t("builder.name_random_tip")}
className="shrink-0"
>
🎲
</Button>
</div>
</div>
<div>
<label className="label">{t("common.language")}</label>
<select
@@ -453,7 +510,7 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
rows={2}
/>
<Button onClick={handleSubmit} loading={submitting} disabled={!canSubmit} fullWidth>
<Button onClick={() => void handleSubmit()} loading={submitting} disabled={!canSubmit} fullWidth>
{submitting ? t("builder.creating") : t("builder.create_button")}
</Button>
</div>
@@ -560,10 +617,3 @@ function ModeButton({
</button>
);
}
// Small helper: useRef but returned as memo-like value for ergonomics.
function useMemoRef<T>(initial: T): { current: T } {
// eslint-disable-next-line react-hooks/exhaustive-deps
const ref = useMemo(() => ({ current: initial }), []);
return ref;
}

View File

@@ -11,6 +11,7 @@ import { Card } from "@/components/ui/Card";
import { JsonEditor } from "@/components/ui/JsonEditor";
import { Spinner } from "@/components/ui/Spinner";
import { SseStatus } from "@/components/sessions/SseStatus";
import { ToolCallBubble } from "@/components/sessions/ToolCallBubble";
type EditorPhase = "idle" | "streaming" | "awaiting_clarification" | "changes_proposed" | "done" | "error";
@@ -24,7 +25,7 @@ interface DiffItem {
interface LogEntry {
id: string;
kind: "comment" | "clarification" | "change_proposed" | "info" | "error";
kind: "comment" | "clarification" | "change_proposed" | "info" | "error" | "tool_call";
text: string;
options?: string[];
diff?: unknown;
@@ -36,6 +37,10 @@ interface LogEntry {
decision?: "accept" | "reject";
/** Whether the accept/reject request is in-flight. */
deciding?: boolean;
/** For tool_call entries: tool name + result + success. */
tool?: string;
toolResult?: unknown;
toolSuccess?: boolean;
}
export interface WorldEditorProps {
@@ -110,6 +115,21 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
]);
break;
}
case "tool_call": {
const d = event.data as { tool: string; arguments?: unknown; result?: unknown; is_success?: boolean };
setLogs((l) => [
...l,
{
id: uid(),
kind: "tool_call",
text: d.tool,
tool: d.tool,
toolResult: d.result,
toolSuccess: d.is_success ?? false,
},
]);
break;
}
case "comment": {
const d = event.data as { text: string };
setLogs((l) => [...l, { id: uid(), kind: "comment", text: d.text }]);
@@ -338,6 +358,8 @@ interface LogEntryViewProps {
}
function LogEntryView({ entry, onAnswer, onAccept, onReject }: LogEntryViewProps) {
const { t } = useTranslation();
if (entry.kind === "clarification") {
return (
<ClarificationCard
@@ -357,9 +379,33 @@ function LogEntryView({ entry, onAnswer, onAccept, onReject }: LogEntryViewProps
);
}
if (entry.kind === "tool_call" && entry.tool) {
return (
<ToolCallBubble
tool={entry.tool}
result={entry.toolResult}
success={entry.toolSuccess ?? false}
/>
);
}
if (entry.kind === "error") {
return <p className="text-xs text-err">{entry.text}</p>;
}
if (entry.kind === "comment") {
// Render comments from the world editor as assistant chat bubbles —
// they're the LLM's narrative reply to the user's instruction.
return (
<div className="max-w-[90%] rounded-lg bg-bg-card px-3 py-2">
<p className="text-xs font-semibold text-fg-muted mb-0.5">
{t("editor.assistant")}
</p>
<p className="whitespace-pre-wrap text-sm text-fg">{entry.text}</p>
</div>
);
}
return <p className="text-xs text-fg-muted">{entry.text}</p>;
}

View File

@@ -83,6 +83,7 @@
"never_played": "Not played yet",
"current_time": "Current time",
"player": "Player",
"default_name": "New World",
"delete_confirm": "Delete this world? This cannot be undone.",
"deleted": "World deleted.",
"delete_failed": "Failed to delete world.",
@@ -112,8 +113,13 @@
"mode_form": "Custom form",
"preset_help": "Start from a pre-configured world template.",
"form_help": "Configure the world yourself with custom rules.",
"setting": "Setting",
"setting_help": "Describe the world setting (e.g. 'post-apocalyptic underground bunker').",
"setting_placeholder": "post-apocalyptic underground bunker",
"world_name": "World name",
"player_name": "Player character name",
"name_random_tip": "Roll a random name",
"name_random_failed": "Failed to fetch a random name.",
"notes": "Notes (optional)",
"notes_help": "Additional instructions for the Game Master.",
"language_en": "English",
@@ -145,6 +151,7 @@
"clarification": "Clarification needed",
"change_proposed": "Change proposed",
"comment": "Comment",
"assistant": "Assistant",
"apply_changes": "Apply changes",
"discard_changes": "Discard changes",
"changes_applied": "Changes applied.",
@@ -178,12 +185,22 @@
"weather": "Weather",
"player": "Player",
"hp": "HP",
"health": "Health",
"mana": "Mana",
"strength": "Strength",
"level": "Level",
"inventory": "Inventory",
"conditions": "Conditions",
"npcs": "NPCs",
"items": "Items",
"plot_rails": "Plot rails",
"hooks": "Hooks",
"current_goals": "Current goals",
"completed_goals": "Completed goals",
"no_env_data": "No environment data yet.",
"no_intro_scene": "This world has no intro scene yet. Go to Edit to generate one.",
"go_to_edit": "Go to Edit",
"edit_world": "Edit world",
"chat": "Chat",
"scene": "Scene",
"you": "You",
@@ -227,6 +244,10 @@
"settings_saved": "Settings saved.",
"settings_save_failed": "Failed to save settings.",
"settings_load_failed": "Failed to load settings.",
"settings_hint": "Each card saves independently. Secret values (api_key) are masked after save.",
"expand_all": "Expand all",
"collapse_all": "Collapse all",
"no_changes": "No changes to save.",
"group_llm": "LLM Configuration",
"group_embeddings": "Embeddings",
"group_qdrant": "Qdrant",
@@ -248,6 +269,12 @@
"logs_prompt": "Prompt",
"logs_response": "Response",
"logs_error": "Error",
"logs_new_count": "{{count}} new",
"logs_pause": "Pause",
"logs_resume": "Resume",
"logs_auto_refresh_on": "Auto-refresh active (page 1, 5s).",
"logs_auto_refresh_off": "Auto-refresh paused.",
"logs_auto_refresh_tip": "Toggle auto-refresh (5s polling on page 1).",
"users_email": "Email",
"users_username": "Username",
"users_admin": "Admin",
@@ -270,6 +297,10 @@
"test_embeddings": "Test embeddings",
"probe_dimension": "Probe dimension",
"recreate_collections": "Recreate collections",
"recreate_collections_hint": "Drops and recreates Qdrant collections based on current embeddings dimension.",
"recreate_dropped": "Dropped",
"recreate_created": "Created",
"test_provider_hint": "If provider=openai and api_url is empty, the system falls back to llm.api_url.",
"api_url": "API URL",
"api_key": "API key",
"model": "Model",
@@ -301,7 +332,46 @@
"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"
"text_replacements_add": "Add rule",
"setting_desc": {
"llm.api_url": "Chat completions endpoint URL for the LLM provider.",
"llm.api_key": "API key for the LLM provider (stored as string).",
"llm.model": "Default model name used for all LLM calls.",
"llm.max_tokens": "Maximum number of tokens to generate per response.",
"llm.timeout_seconds": "Request timeout in seconds.",
"llm.temperature_orchestrator": "Sampling temperature for the orchestrator phase (02).",
"llm.temperature_writer": "Sampling temperature for the scene writer phase (02).",
"llm.text_replacements": "JSON list of {from, to} text replacements applied to all LLM scene output.",
"embeddings.provider": "Embeddings provider: 'offline_hash' (no API) or 'openai'.",
"embeddings.api_url": "Embeddings API URL. If empty, falls back to llm.api_url.",
"embeddings.api_key": "Embeddings API key. If empty, falls back to llm.api_key.",
"embeddings.model": "Embeddings model name (default: text-embedding-3-small).",
"embeddings.dimension": "Vector dimension used by the Qdrant collection.",
"embeddings.batch_size": "Number of texts to embed per API request.",
"embeddings.timeout_seconds": "Embeddings API request timeout in seconds.",
"embeddings.cache_ttl_seconds": "Time-to-live for the in-memory embeddings cache.",
"embeddings.max_text_chars": "Maximum characters of text passed to the embeddings API per call.",
"qdrant.url": "Qdrant server URL.",
"qdrant.api_key": "Qdrant API key (optional — only if the server requires auth).",
"qdrant.collection_name": "Name of the Qdrant collection used for entity vectors.",
"context.guaranteed_messages": "Number of recent chat messages always kept in the LLM context window.",
"context.compression_threshold_messages": "When recent message count exceeds this, older messages are summarized.",
"context.compression_threshold_tokens": "When estimated token count exceeds this, older messages are summarized.",
"context.scene_text_truncate_tokens": "Maximum tokens of scene_text retained per step.",
"context.safety_margin_tokens": "Reserved token budget kept between context and model max_tokens.",
"context.auto_rag_on_entity_mention": "If true, automatically retrieve entity context when an entity is mentioned.",
"game.max_substeps_per_iteration": "Maximum number of tool-call sub-steps per player iteration.",
"game.max_suggested_actions": "Maximum number of suggested actions returned to the player.",
"game.deferred_triggers_enabled": "If true, deferred triggers may fire on subsequent iterations.",
"ui.page_title": "Page <title> shown in the browser tab.",
"ui.header_title": "Title shown in the navbar (falls back to page_title).",
"ui.favicon_url": "URL of the favicon.",
"ui.logo_url": "URL of the navbar logo image.",
"ui.og_image_url": "URL of the Open Graph image used for social previews.",
"ui.character_names.en": "Comma-separated list of English character names for the random-name button.",
"ui.character_names.ru": "Comma-separated list of Russian character names for the random-name button.",
"admin.setup_token": "Token required to create the first admin account."
}
},
"errors": {
"generic": "Something went wrong.",

View File

@@ -83,6 +83,7 @@
"never_played": "Ещё не играли",
"current_time": "Текущее время",
"player": "Игрок",
"default_name": "Новый Мир",
"delete_confirm": "Удалить этот мир? Действие необратимо.",
"deleted": "Мир удалён.",
"delete_failed": "Не удалось удалить мир.",
@@ -112,8 +113,13 @@
"mode_form": "Своя форма",
"preset_help": "Начать с готового шаблона мира.",
"form_help": "Настроить мир самостоятельно с собственными правилами.",
"setting": "Сеттинг",
"setting_help": "Опишите сеттинг мира (например, «постапокалиптический подземный бункер»).",
"setting_placeholder": "постапокалиптический подземный бункер",
"world_name": "Название мира",
"player_name": "Имя персонажа игрока",
"name_random_tip": "Сгенерировать случайное имя",
"name_random_failed": "Не удалось получить случайное имя.",
"notes": "Заметки (необязательно)",
"notes_help": "Дополнительные инструкции для мастера игры.",
"language_en": "Английский",
@@ -145,6 +151,7 @@
"clarification": "Требуется уточнение",
"change_proposed": "Предложено изменение",
"comment": "Комментарий",
"assistant": "Ассистент",
"apply_changes": "Применить изменения",
"discard_changes": "Отменить изменения",
"changes_applied": "Изменения применены.",
@@ -178,12 +185,22 @@
"weather": "Погода",
"player": "Игрок",
"hp": "ОЗ",
"health": "Здоровье",
"mana": "Мана",
"strength": "Сила",
"level": "Уровень",
"inventory": "Инвентарь",
"conditions": "Состояния",
"npcs": "NPC",
"items": "Предметы",
"plot_rails": "Сюжетные линии",
"hooks": "Зацепки",
"current_goals": "Текущие цели",
"completed_goals": "Завершённые цели",
"no_env_data": "Данные об окружении пока отсутствуют.",
"no_intro_scene": "У этого мира ещё нет вступительной сцены. Перейдите в редактор, чтобы сгенерировать её.",
"go_to_edit": "Перейти в редактор",
"edit_world": "Редактировать мир",
"chat": "Чат",
"scene": "Сцена",
"you": "Вы",
@@ -227,6 +244,10 @@
"settings_saved": "Настройки сохранены.",
"settings_save_failed": "Не удалось сохранить настройки.",
"settings_load_failed": "Не удалось загрузить настройки.",
"settings_hint": "Каждая карточка сохраняется независимо. Секретные значения (api_key) маскируются после сохранения.",
"expand_all": "Развернуть все",
"collapse_all": "Свернуть все",
"no_changes": "Нет изменений для сохранения.",
"group_llm": "Конфигурация LLM",
"group_embeddings": "Эмбеддинги",
"group_qdrant": "Qdrant",
@@ -248,6 +269,12 @@
"logs_prompt": "Запрос",
"logs_response": "Ответ",
"logs_error": "Ошибка",
"logs_new_count": "{{count}} нов.",
"logs_pause": "Пауза",
"logs_resume": "Возобновить",
"logs_auto_refresh_on": "Автообновление активно (стр. 1, 5с).",
"logs_auto_refresh_off": "Автообновление приостановлено.",
"logs_auto_refresh_tip": "Переключить автообновление (опрос каждые 5с на стр. 1).",
"users_email": "Почта",
"users_username": "Имя пользователя",
"users_admin": "Админ",
@@ -270,6 +297,10 @@
"test_embeddings": "Тест эмбеддингов",
"probe_dimension": "Определить размерность",
"recreate_collections": "Пересоздать коллекции",
"recreate_collections_hint": "Удаляет и пересоздаёт коллекции Qdrant на основе текущей размерности эмбеддингов.",
"recreate_dropped": "Удалено",
"recreate_created": "Создано",
"test_provider_hint": "Если provider=openai и api_url пуст, система использует llm.api_url.",
"api_url": "URL API",
"api_key": "Ключ API",
"model": "Модель",
@@ -301,7 +332,46 @@
"text_replacements_empty": "Правил замены пока нет. Нажмите «Добавить правило», чтобы создать.",
"text_replacements_from": "С",
"text_replacements_to": "На",
"text_replacements_add": "Добавить правило"
"text_replacements_add": "Добавить правило",
"setting_desc": {
"llm.api_url": "URL endpoint chat completions провайдера LLM.",
"llm.api_key": "API-ключ провайдера LLM (хранится строкой).",
"llm.model": "Имя модели по умолчанию для всех вызовов LLM.",
"llm.max_tokens": "Максимальное число токенов в ответе.",
"llm.timeout_seconds": "Таймаут запроса в секундах.",
"llm.temperature_orchestrator": "Температура сэмплинга для фазы оркестратора (02).",
"llm.temperature_writer": "Температура сэмплинга для фазы писателя сцены (02).",
"llm.text_replacements": "JSON-список замен {from, to}, применяемых ко всему выводу LLM.",
"embeddings.provider": "Провайдер эмбеддингов: 'offline_hash' (без API) или 'openai'.",
"embeddings.api_url": "URL API эмбеддингов. Если пусто, используется llm.api_url.",
"embeddings.api_key": "API-ключ эмбеддингов. Если пусто, используется llm.api_key.",
"embeddings.model": "Имя модели эмбеддингов (по умолчанию: text-embedding-3-small).",
"embeddings.dimension": "Размерность векторов в коллекции Qdrant.",
"embeddings.batch_size": "Количество текстов на один API-запрос эмбеддингов.",
"embeddings.timeout_seconds": "Таймаут API-запроса эмбеддингов в секундах.",
"embeddings.cache_ttl_seconds": "Время жизни in-memory кэша эмбеддингов.",
"embeddings.max_text_chars": "Максимум символов текста на один вызов API эмбеддингов.",
"qdrant.url": "URL сервера Qdrant.",
"qdrant.api_key": "API-ключ Qdrant (опционально — только если сервер требует аутентификацию).",
"qdrant.collection_name": "Имя коллекции Qdrant для векторов сущностей.",
"context.guaranteed_messages": "Количество недавних сообщений чата, всегда удерживаемых в контексте LLM.",
"context.compression_threshold_messages": "При превышении этого числа сообщений старые суммируются.",
"context.compression_threshold_tokens": "При превышении оценки токенов старые сообщения суммируются.",
"context.scene_text_truncate_tokens": "Максимум токенов scene_text, сохраняемых на шаг.",
"context.safety_margin_tokens": "Резерв токенов между контекстом и max_tokens модели.",
"context.auto_rag_on_entity_mention": "Если true, автоматически извлекать контекст сущности при её упоминании.",
"game.max_substeps_per_iteration": "Максимум подшагов вызова инструментов на итерацию игрока.",
"game.max_suggested_actions": "Максимум предложенных действий, возвращаемых игроку.",
"game.deferred_triggers_enabled": "Если true, отложенные триггеры могут срабатывать на последующих итерациях.",
"ui.page_title": "<title> страницы во вкладке браузера.",
"ui.header_title": "Заголовок в навбаре (по умолчанию page_title).",
"ui.favicon_url": "URL фавикона.",
"ui.logo_url": "URL логотипа в навбаре.",
"ui.og_image_url": "URL Open Graph изображения для соц. превью.",
"ui.character_names.en": "Список английских имён персонажей через запятую для кнопки случайного имени.",
"ui.character_names.ru": "Список русских имён персонажей через запятую для кнопки случайного имени.",
"admin.setup_token": "Токен для создания первого аккаунта администратора."
}
},
"errors": {
"generic": "Что-то пошло не так.",

View File

@@ -478,4 +478,18 @@ export const AdminApi = {
export const MiscApi = {
health: () => request<HealthResponse>("/health"),
publicSettings: () => request<PublicSettings>("/settings/public"),
/**
* Fetch a random character name from the name bank for the given language.
* Endpoint: GET /api/names/{language} (no auth).
* Returns: `{name, language, count}`.
*/
randomName: (language: string) =>
request<RandomNameResponse>(`/names/${encodeURIComponent(language)}`),
};
/** Response from GET /api/names/{language}. */
export interface RandomNameResponse {
name: string;
language: string;
count: number;
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/stores/authStore";
import { SettingsPanel } from "@/components/admin/SettingsPanel";
@@ -9,21 +9,27 @@ import { TestButtons } from "@/components/admin/TestButtons";
import { IconsPanel } from "@/components/admin/IconsPanel";
import { cn } from "@/lib/cn";
type Tab = "settings" | "logs" | "users" | "stats" | "test" | "icons";
type Tab = "stats" | "logs" | "users" | "settings" | "test" | "icons";
const TABS: Array<{ id: Tab; labelKey: string }> = [
{ id: "settings", labelKey: "admin.tab_settings" },
{ id: "logs", labelKey: "admin.tab_logs" },
{ id: "users", labelKey: "admin.tab_users" },
{ id: "stats", labelKey: "admin.tab_stats" },
{ id: "test", labelKey: "admin.tab_test" },
{ id: "icons", labelKey: "admin.tab_icons" },
const TABS: Array<{ id: Tab; labelKey: string; path: string }> = [
{ id: "stats", labelKey: "admin.tab_stats", path: "stats" },
{ id: "logs", labelKey: "admin.tab_logs", path: "logs" },
{ id: "users", labelKey: "admin.tab_users", path: "users" },
{ id: "settings", labelKey: "admin.tab_settings", path: "settings" },
{ id: "test", labelKey: "admin.tab_test", path: "test" },
{ id: "icons", labelKey: "admin.tab_icons", path: "icons" },
];
const VALID_TABS = new Set<string>(TABS.map((t) => t.id));
export function AdminPage() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const [tab, setTab] = useState<Tab>("stats");
const navigate = useNavigate();
const { tab = "stats" } = useParams<{ tab?: string }>();
// Defensive: if the route param is somehow invalid, fall back to stats.
const activeTab: Tab = VALID_TABS.has(tab) ? (tab as Tab) : "stats";
if (!user?.is_admin) {
return (
@@ -33,6 +39,10 @@ export function AdminPage() {
);
}
const goTab = (next: Tab) => {
navigate(`/admin/${next}`);
};
return (
<div className="mx-auto max-w-7xl space-y-4 p-4">
<header>
@@ -43,14 +53,14 @@ export function AdminPage() {
{TABS.map((tabDef) => (
<button
key={tabDef.id}
onClick={() => setTab(tabDef.id)}
onClick={() => goTab(tabDef.id)}
className={cn(
"px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
tab === tabDef.id
activeTab === tabDef.id
? "border-accent text-fg"
: "border-transparent text-fg-muted hover:text-fg hover:border-fg-dim/30",
)}
aria-current={tab === tabDef.id ? "page" : undefined}
aria-current={activeTab === tabDef.id ? "page" : undefined}
>
{t(tabDef.labelKey)}
</button>
@@ -58,12 +68,12 @@ export function AdminPage() {
</nav>
<section>
{tab === "settings" && <SettingsPanel />}
{tab === "logs" && <LlmLogsTable />}
{tab === "users" && <UsersTable />}
{tab === "stats" && <StatsPanel />}
{tab === "test" && <TestButtons />}
{tab === "icons" && <IconsPanel />}
{activeTab === "stats" && <StatsPanel />}
{activeTab === "logs" && <LlmLogsTable />}
{activeTab === "users" && <UsersTable />}
{activeTab === "settings" && <SettingsPanel />}
{activeTab === "test" && <TestButtons />}
{activeTab === "icons" && <IconsPanel />}
</section>
</div>
);

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/sessionStore";
import { useToastStore } from "@/stores/toastStore";
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
import { toErrorMessage } from "@/lib/api";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
@@ -10,13 +11,15 @@ import { Spinner } from "@/components/ui/Spinner";
import { ChatView } from "@/components/sessions/ChatView";
import { ActionInput } from "@/components/sessions/ActionInput";
import { SseStatus } from "@/components/sessions/SseStatus";
import type { Environment, PlotRail } from "@/types";
import type { Environment, PlotRailsContainer } from "@/types";
export function PlayPage() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const pushToast = useToastStore((s) => s.push);
const settings = useUiSettingsStore((s) => s.settings);
const headerTitle = selectHeaderTitle(settings);
const world = useSessionStore((s) => s.world);
const environment = useSessionStore((s) => s.environment);
@@ -53,6 +56,17 @@ export function PlayPage() {
}
}, [id, world, redirected, navigate, pushToast, t]);
// Page title: "{world.name} | {headerTitle}"
useEffect(() => {
if (world) {
document.title = `${world.name} | ${headerTitle}`;
}
return () => {
// Restore default title (App.tsx effect will re-apply on next mount).
document.title = headerTitle;
};
}, [world, headerTitle]);
if (!id) return null;
if (loading && !world) {
@@ -76,8 +90,26 @@ export function PlayPage() {
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 plotRailsContainer = normalizePlotRails(world.plot_rails);
const envHasData = Boolean(
environment && (
environment.location ||
environment.time_of_day ||
environment.weather ||
player ||
(Array.isArray(environment.npcs) && environment.npcs.length > 0) ||
(Array.isArray(environment.items) && environment.items.length > 0)
),
);
const plotRailsHasData = Boolean(
plotRailsContainer &&
(
(plotRailsContainer.hooks && plotRailsContainer.hooks.length > 0) ||
(plotRailsContainer.current_goals && plotRailsContainer.current_goals.length > 0) ||
(plotRailsContainer.completed_goals && plotRailsContainer.completed_goals.length > 0)
),
);
const handleSend = (action: string) => {
void sendAction(id, action, "manual").catch((err) => {
@@ -112,66 +144,30 @@ export function PlayPage() {
{/* Environment panel */}
<aside className="order-2 lg:order-1 lg:w-72 lg:shrink-0 overflow-y-auto space-y-3">
<Card title={t("play.environment")}>
<dl className="space-y-1 text-sm">
<EnvRow label={t("play.location")} value={environment?.location} />
<EnvRow label={t("play.time_of_day")} value={environment?.time_of_day} />
<EnvRow label={t("play.weather")} value={environment?.weather} />
</dl>
{!envHasData ? (
<p className="text-xs text-fg-muted">{t("play.no_env_data")}</p>
) : (
<dl className="space-y-1 text-sm">
<EnvRow label={t("play.location")} value={environment?.location} />
<EnvRow label={t("play.time_of_day")} value={environment?.time_of_day} />
<EnvRow label={t("play.weather")} value={environment?.weather} />
</dl>
)}
</Card>
{player && (
<Card title={t("play.player")}>
<dl className="space-y-1 text-sm">
<EnvRow label={t("common.name")} value={player.name} />
{typeof player.hp === "number" && (
<EnvRow
label={t("play.hp")}
value={player.max_hp != null ? `${player.hp} / ${player.max_hp}` : String(player.hp)}
/>
)}
{typeof player.level === "number" && (
<EnvRow label={t("play.level")} value={String(player.level)} />
)}
{Array.isArray(player.conditions) && player.conditions.length > 0 && (
<div>
<p className="text-xs uppercase text-fg-dim">{t("play.conditions")}</p>
<div className="mt-1 flex flex-wrap gap-1">
{player.conditions.map((c, i) => (
<span key={i} className="badge bg-warn/15 text-warn">{c}</span>
))}
</div>
</div>
)}
{Array.isArray(player.inventory) && player.inventory.length > 0 && (
<div>
<p className="text-xs uppercase text-fg-dim">{t("play.inventory")}</p>
<ul className="mt-1 list-disc pl-5 text-fg-muted">
{player.inventory.slice(0, 8).map((it, i) => (
<li key={i}>{typeof it === "string" ? it : (it as { name?: string }).name || JSON.stringify(it)}</li>
))}
{player.inventory.length > 8 && <li> +{player.inventory.length - 8}</li>}
</ul>
</div>
)}
</dl>
<PlayerPanel environment={environment} />
</Card>
)}
{plotRails.length > 0 && (
<Card title={t("play.plot_rails")}>
<ul className="space-y-1.5 text-sm">
{plotRails.map((r, i) => (
<li key={r.id || i} className="rounded-md bg-bg-soft p-2">
<p className="font-medium text-fg">{r.title || `Rail ${i + 1}`}</p>
{r.description && <p className="text-xs text-fg-muted">{r.description}</p>}
{r.status && (
<span className="badge mt-1 bg-bg-card text-fg-muted">{r.status}</span>
)}
</li>
))}
</ul>
</Card>
)}
<Card title={t("play.plot_rails")}>
{!plotRailsHasData || !plotRailsContainer ? (
<p className="text-xs text-fg-muted">{t("play.no_env_data")}</p>
) : (
<PlotRailsView rails={plotRailsContainer} />
)}
</Card>
<Card>
<div className="flex flex-col gap-2">
@@ -203,8 +199,17 @@ export function PlayPage() {
<main className="order-1 lg:order-2 flex min-h-0 flex-1 flex-col">
<Card className="flex min-h-0 flex-1 flex-col p-0">
<header className="flex items-center justify-between border-b border-fg-dim/20 p-3">
<div>
<h2 className="text-base font-semibold text-fg">{world.name}</h2>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-base font-semibold text-fg">{world.name}</h2>
<Link
to={`/worlds/${world.id}/edit`}
className="shrink-0 rounded-md border border-fg-dim/30 px-2 py-0.5 text-xs text-fg-muted hover:bg-bg-soft hover:text-fg"
title={t("play.edit_world")}
>
{t("common.edit")}
</Link>
</div>
<p className="text-xs text-fg-muted">
{(() => {
// Prefer the human-readable form; fall back to the raw
@@ -251,6 +256,19 @@ export function PlayPage() {
);
}
/**
* Normalize the plot_rails field — the backend may return either an
* old-style array of PlotRail objects or a new-style container object
* with hooks/current_goals/completed_goals. We always return the
* container shape (or null).
*/
function normalizePlotRails(raw: unknown): PlotRailsContainer | null {
if (raw == null) return null;
if (Array.isArray(raw)) return null; // old-style array, no container fields
if (typeof raw === "object") return raw as PlotRailsContainer;
return null;
}
function EnvRow({ label, value }: { label: string; value: unknown }) {
if (value == null || value === "") return null;
const text = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
@@ -261,3 +279,138 @@ function EnvRow({ label, value }: { label: string; value: unknown }) {
</div>
);
}
/** Render the player block: name, stats (health/mana/strength) with a
* progress bar for health, plus inventory list. */
function PlayerPanel({ environment }: { environment: Environment | null }) {
const { t } = useTranslation();
const player = environment?.player;
if (!player) return null;
// Support both old (hp/max_hp) and new (health/mana/strength) shapes.
const health = numField(player.health ?? player.hp);
const maxHealth = numField(player.max_health ?? player.max_hp);
const mana = numField(player.mana);
const strength = numField(player.strength);
const inventory = Array.isArray(player.inventory) ? player.inventory : [];
const conditions = Array.isArray(player.conditions) ? player.conditions : [];
return (
<div className="space-y-2 text-sm">
<EnvRow label={t("common.name")} value={player.name} />
{health != null && (
<div>
<div className="flex items-center justify-between text-xs">
<span className="text-fg-dim">{t("play.health")}</span>
<span className="text-fg">
{health}{maxHealth != null ? ` / ${maxHealth}` : ""}
</span>
</div>
{maxHealth != null && maxHealth > 0 && (
<div className="mt-1 h-2 w-full overflow-hidden rounded-full bg-bg-soft">
<div
className="h-full bg-err/70"
style={{ width: `${Math.max(0, Math.min(100, (health / maxHealth) * 100))}%` }}
/>
</div>
)}
</div>
)}
{mana != null && (
<EnvRow label={t("play.mana")} value={String(mana)} />
)}
{strength != null && (
<EnvRow label={t("play.strength")} value={String(strength)} />
)}
{typeof player.level === "number" && (
<EnvRow label={t("play.level")} value={String(player.level)} />
)}
{conditions.length > 0 && (
<div>
<p className="text-xs uppercase text-fg-dim">{t("play.conditions")}</p>
<div className="mt-1 flex flex-wrap gap-1">
{conditions.map((c, i) => (
<span key={i} className="badge bg-warn/15 text-warn">{c}</span>
))}
</div>
</div>
)}
{inventory.length > 0 && (
<div>
<p className="text-xs uppercase text-fg-dim">{t("play.inventory")}</p>
<ul className="mt-1 list-disc pl-5 text-fg-muted">
{inventory.slice(0, 12).map((it, i) => (
<li key={i}>
{typeof it === "string"
? it
: (it as { name?: string })?.name
|| (it as { title?: string })?.title
|| JSON.stringify(it)}
</li>
))}
{inventory.length > 12 && <li> +{inventory.length - 12}</li>}
</ul>
</div>
)}
</div>
);
}
function PlotRailsView({ rails }: { rails: PlotRailsContainer }) {
const { t } = useTranslation();
const hooks = rails.hooks || [];
const currentGoals = rails.current_goals || [];
const completedGoals = rails.completed_goals || [];
return (
<div className="space-y-2 text-sm">
{hooks.length > 0 && (
<div>
<p className="text-xs uppercase text-fg-dim">{t("play.hooks")}</p>
<ul className="mt-1 list-disc pl-5 text-fg">
{hooks.map((h, i) => (
<li key={i}>{h}</li>
))}
</ul>
</div>
)}
{currentGoals.length > 0 && (
<div>
<p className="text-xs uppercase text-fg-dim">{t("play.current_goals")}</p>
<ul className="mt-1 list-disc pl-5 text-fg">
{currentGoals.map((g, i) => (
<li key={i}>{g}</li>
))}
</ul>
</div>
)}
{completedGoals.length > 0 && (
<details>
<summary className="cursor-pointer text-xs uppercase text-fg-dim">
{t("play.completed_goals")} ({completedGoals.length})
</summary>
<ul className="mt-1 list-disc pl-5 text-fg-muted line-through">
{completedGoals.map((g, i) => (
<li key={i}>{g}</li>
))}
</ul>
</details>
)}
</div>
);
}
/** Coerce an unknown value to a number, or return null. */
function numField(v: unknown): number | null {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string") {
const n = Number(v);
if (Number.isFinite(n)) return n;
}
return null;
}

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { WorldsApi } from "@/lib/api";
import { useWorldsStore } from "@/stores/worldsStore";
import { useToastStore } from "@/stores/toastStore";
import { useUiSettingsStore, selectHeaderTitle } from "@/stores/uiSettingsStore";
import { Button } from "@/components/ui/Button";
import { Spinner } from "@/components/ui/Spinner";
import { WorldEditor } from "@/components/worlds/WorldEditor";
@@ -19,6 +20,8 @@ export function WorldEditPage() {
const fetchWorld = useWorldsStore((s) => s.fetchWorld);
const setCurrentWorld = useWorldsStore((s) => s.setCurrentWorld);
const pushToast = useToastStore((s) => s.push);
const settings = useUiSettingsStore((s) => s.settings);
const headerTitle = selectHeaderTitle(settings);
useEffect(() => {
if (!id) return;
@@ -28,6 +31,16 @@ export function WorldEditPage() {
return () => setCurrentWorld(null);
}, [id, fetchWorld, setCurrentWorld, pushToast, t]);
// Page title: "{world.name} | {headerTitle}"
useEffect(() => {
if (world) {
document.title = `${world.name} | ${headerTitle}`;
}
return () => {
document.title = headerTitle;
};
}, [world, headerTitle]);
const refreshWorld = useCallback(async () => {
if (!id) return;
try {

View File

@@ -38,6 +38,15 @@ function applyToDocument(s: PublicSettings): void {
}
}
/**
* Compute the effective header title shown in the navbar: prefer
* `header_title` (set by admin), fall back to `page_title`, then "AI-RPG".
*/
export function selectHeaderTitle(s: PublicSettings | null): string {
if (!s) return "AI-RPG";
return s.header_title || s.page_title || "AI-RPG";
}
export const useUiSettingsStore = create<UiSettingsState>((set, get) => ({
settings: null,
loaded: false,

View File

@@ -114,6 +114,18 @@ export interface PlotRail {
[key: string]: unknown;
}
/**
* New-style plot rails container (backend returns an object with hooks,
* current_goals, completed_goals arrays). Older backends returned an array
* of PlotRail objects — both shapes are supported.
*/
export interface PlotRailsContainer {
hooks?: string[];
current_goals?: string[];
completed_goals?: string[];
[key: string]: unknown;
}
export interface World {
id: string;
owner_id: string;
@@ -126,7 +138,7 @@ export interface World {
schemas: Schemas;
environment_schema: unknown;
environment: Environment;
plot_rails: PlotRail[];
plot_rails: PlotRail[] | PlotRailsContainer;
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.
@@ -288,6 +300,9 @@ export interface AdminStats {
export interface PublicSettings {
page_title: string;
/** Optional header title for the navbar (falls back to page_title
* on the backend). May be absent on older backend versions. */
header_title?: string;
favicon_url: string;
logo_url: string;
og_image_url: string;