This commit is contained in:
Mikan
2026-06-19 11:28:04 +03:00
commit 53c89829a8
80 changed files with 12482 additions and 0 deletions

91
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,91 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuthStore } from "@/store/auth";
import { Navbar } from "@/components/ui/Navbar";
import { HomePage } from "@/pages/HomePage";
import { LoginPage } from "@/pages/LoginPage";
import { RegisterPage } from "@/pages/RegisterPage";
import { AdminSetupPage } from "@/pages/AdminSetupPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { WorldCreatePage } from "@/pages/WorldCreatePage";
import { WorldBuilderPage } from "@/pages/WorldBuilderPage";
import { WorldEditPage } from "@/pages/WorldEditPage";
import { SessionPage } from "@/pages/SessionPage";
import { AdminPanelPage } from "@/pages/AdminPanelPage";
function PrivateRoute({ children }: { children: JSX.Element }) {
const token = useAuthStore((s) => s.token);
if (!token) return <Navigate to="/login" replace />;
return children;
}
function AdminRoute({ children }: { children: JSX.Element }) {
const { token, user } = useAuthStore();
if (!token) return <Navigate to="/login" replace />;
if (!user?.is_admin) return <Navigate to="/dashboard" replace />;
return children;
}
export default function App() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/admin/setup" element={<AdminSetupPage />} />
<Route
path="/dashboard"
element={
<PrivateRoute>
<DashboardPage />
</PrivateRoute>
}
/>
<Route
path="/worlds/new"
element={
<PrivateRoute>
<WorldCreatePage />
</PrivateRoute>
}
/>
<Route
path="/worlds/:id/edit"
element={
<PrivateRoute>
<WorldEditPage />
</PrivateRoute>
}
/>
<Route
path="/worlds/builder"
element={
<PrivateRoute>
<WorldBuilderPage />
</PrivateRoute>
}
/>
<Route
path="/sessions/:id"
element={
<PrivateRoute>
<SessionPage />
</PrivateRoute>
}
/>
<Route
path="/admin"
element={
<AdminRoute>
<AdminPanelPage />
</AdminRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
);
}

190
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,190 @@
import axios, { AxiosError } from "axios";
import type {
GlossaryEntry,
LlmLog,
Message,
Preset,
Session,
SettingsOut,
TokenOut,
Trigger,
User,
World,
WorldBuilderReply,
} from "@/types";
import { useAuthStore } from "@/store/auth";
const API_BASE = "/api";
const api = axios.create({
baseURL: API_BASE,
headers: { "Content-Type": "application/json" },
});
// Inject auth token
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Auto-logout on 401
api.interceptors.response.use(
(r) => r,
(err: AxiosError) => {
if (err.response?.status === 401) {
useAuthStore.getState().logout();
}
return Promise.reject(err);
}
);
export const authApi = {
register: async (email: string, username: string, password: string): Promise<TokenOut> => {
const { data } = await api.post("/auth/register", { email, username, password });
return data;
},
login: async (email: string, password: string): Promise<TokenOut> => {
const { data } = await api.post("/auth/login", { email, password });
return data;
},
me: async (): Promise<User> => {
const { data } = await api.get("/auth/me");
return data;
},
adminSetup: async (token: string, email: string, username: string, password: string): Promise<TokenOut> => {
const { data } = await api.post("/auth/admin-setup", { token, email, username, password });
return data;
},
};
export const adminApi = {
getSettings: async (): Promise<SettingsOut> => {
const { data } = await api.get("/admin/settings");
return data;
},
updateSettings: async (values: Record<string, any>): Promise<SettingsOut> => {
const { data } = await api.put("/admin/settings", { values });
return data;
},
testEmbeddings: async (overrides?: Record<string, any>): Promise<{
ok: boolean;
provider?: string;
base_url?: string;
model?: string;
dim?: number;
sample_norm?: number;
error?: string;
}> => {
const { data } = await api.post("/admin/embeddings/test", { overrides: overrides || {} });
return data;
},
listLlmLogs: async (limit = 50, offset = 0): Promise<LlmLog[]> => {
const { data } = await api.get(`/admin/llm-logs?limit=${limit}&offset=${offset}`);
return data;
},
getLlmLog: async (id: string): Promise<any> => {
const { data } = await api.get(`/admin/llm-logs/${id}`);
return data;
},
listUsers: async (): Promise<any[]> => {
const { data } = await api.get("/admin/users");
return data;
},
};
export const presetsApi = {
list: async (language?: string): Promise<Preset[]> => {
const url = language ? `/presets?language=${language}` : "/presets";
const { data } = await api.get(url);
return data;
},
get: async (id: string): Promise<Preset> => {
const { data } = await api.get(`/presets/${id}`);
return data;
},
create: async (payload: Partial<Preset>): Promise<Preset> => {
const { data } = await api.post("/presets", payload);
return data;
},
};
export const worldsApi = {
list: async (): Promise<World[]> => {
const { data } = await api.get("/worlds");
return data;
},
get: async (id: string): Promise<World> => {
const { data } = await api.get(`/worlds/${id}`);
return data;
},
create: async (name: string, language: string, preset_id?: string): Promise<World> => {
const { data } = await api.post("/worlds", { name, language, preset_id });
return data;
},
update: async (id: string, payload: Partial<World>): Promise<World> => {
const { data } = await api.patch(`/worlds/${id}`, payload);
return data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/worlds/${id}`);
},
builderStart: async (payload: {
world_name: string;
language: string;
preset_id?: string;
setting_brief: string;
character_brief: string;
rules_brief: string;
notes: string;
}): Promise<WorldBuilderReply> => {
const { data } = await api.post("/worlds/builder/start", payload);
return data;
},
builderContinue: async (session_id: string, message: string): Promise<WorldBuilderReply> => {
const { data } = await api.post("/worlds/builder/continue", { session_id, message });
return data;
},
builderCommit: async (session_id: string, name?: string): Promise<World> => {
const { data } = await api.post("/worlds/builder/commit", { session_id, name });
return data;
},
};
export const sessionsApi = {
list: async (): Promise<Session[]> => {
const { data } = await api.get("/sessions");
return data;
},
create: async (world_id: string, title?: string): Promise<Session> => {
const { data } = await api.post("/sessions", { world_id, title });
return data;
},
get: async (id: string): Promise<Session> => {
const { data } = await api.get(`/sessions/${id}`);
return data;
},
listMessages: async (id: string, includeHidden = false): Promise<Message[]> => {
const { data } = await api.get(`/sessions/${id}/messages?include_hidden=${includeHidden}`);
return data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/sessions/${id}`);
},
};
export const miscApi = {
listGlossary: async (worldId: string, kind?: string): Promise<GlossaryEntry[]> => {
const url = kind ? `/worlds/${worldId}/glossary?kind=${kind}` : `/worlds/${worldId}/glossary`;
const { data } = await api.get(url);
return data;
},
listTriggers: async (sessionId: string, includeFired = true): Promise<Trigger[]> => {
const { data } = await api.get(`/sessions/${sessionId}/triggers?include_fired=${includeFired}`);
return data;
},
};
export const SSE_ENDPOINT = "/api/sessions";

View File

@@ -0,0 +1,37 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "./cn";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger" | "outline";
size?: "sm" | "md" | "lg";
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "primary", size = "md", ...props }, ref) => {
const variants = {
primary: "bg-accent-500 hover:bg-accent-600 text-white shadow-md shadow-accent-500/20",
secondary: "bg-ink-800 hover:bg-ink-700 text-ink-100 border border-ink-700",
ghost: "bg-transparent hover:bg-ink-800 text-ink-200",
danger: "bg-red-600 hover:bg-red-700 text-white",
outline: "bg-transparent border border-ink-600 hover:bg-ink-800 text-ink-100",
};
const sizes = {
sm: "px-2.5 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
};
return (
<button
ref={ref}
className={cn(
"rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
variants[variant],
sizes[size],
className
)}
{...props}
/>
);
}
);
Button.displayName = "Button";

View File

@@ -0,0 +1,26 @@
import { ReactNode } from "react";
import { cn } from "./cn";
export function Card({ className, children }: { className?: string; children: ReactNode }) {
return (
<div className={cn("bg-ink-900/70 border border-ink-800 rounded-xl backdrop-blur-sm", className)}>
{children}
</div>
);
}
export function CardHeader({ title, subtitle, action }: { title: string; subtitle?: string; action?: ReactNode }) {
return (
<div className="flex items-start justify-between p-4 border-b border-ink-800">
<div>
<h3 className="text-sm font-semibold text-ink-100">{title}</h3>
{subtitle && <p className="text-xs text-ink-400 mt-0.5">{subtitle}</p>}
</div>
{action}
</div>
);
}
export function CardBody({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn("p-4", className)}>{children}</div>;
}

View File

@@ -0,0 +1,70 @@
import { InputHTMLAttributes, TextareaHTMLAttributes, forwardRef } from "react";
import { cn } from "./cn";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, id, ...props }, ref) => {
const inputId = id || props.name;
return (
<div className="space-y-1">
{label && (
<label htmlFor={inputId} className="block text-xs font-medium text-ink-300">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(
"w-full px-3 py-2 rounded-lg bg-ink-900 border border-ink-700 text-ink-100 placeholder-ink-500",
"focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent",
"transition-colors",
error && "border-red-500",
className
)}
{...props}
/>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
);
}
);
Input.displayName = "Input";
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, id, ...props }, ref) => {
const inputId = id || props.name;
return (
<div className="space-y-1">
{label && (
<label htmlFor={inputId} className="block text-xs font-medium text-ink-300">
{label}
</label>
)}
<textarea
ref={ref}
id={inputId}
className={cn(
"w-full px-3 py-2 rounded-lg bg-ink-900 border border-ink-700 text-ink-100 placeholder-ink-500",
"focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent",
"transition-colors resize-y min-h-[80px]",
error && "border-red-500",
className
)}
{...props}
/>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
);
}
);
Textarea.displayName = "Textarea";

View File

@@ -0,0 +1,60 @@
import { ReactNode, useEffect } from "react";
import { X } from "lucide-react";
import { cn } from "./cn";
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
size?: "sm" | "md" | "lg" | "xl";
}
export function Modal({ open, onClose, title, children, size = "md" }: ModalProps) {
useEffect(() => {
if (!open) return;
const onEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onEsc);
return () => document.removeEventListener("keydown", onEsc);
}, [open, onClose]);
if (!open) return null;
const sizes = {
sm: "max-w-md",
md: "max-w-2xl",
lg: "max-w-4xl",
xl: "max-w-6xl",
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className={cn(
"w-full bg-ink-900 border border-ink-700 rounded-xl shadow-2xl max-h-[90vh] flex flex-col",
sizes[size]
)}
onClick={(e) => e.stopPropagation()}
>
{title && (
<div className="flex items-center justify-between p-4 border-b border-ink-800">
<h2 className="text-base font-semibold text-ink-100">{title}</h2>
<button
onClick={onClose}
className="p-1 rounded text-ink-400 hover:text-ink-100 hover:bg-ink-800"
aria-label="Close"
>
<X size={18} />
</button>
</div>
)}
<div className="overflow-y-auto p-4 flex-1">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { LogOut, Shield, Globe, BookOpen } from "lucide-react";
import { useAuthStore } from "@/store/auth";
import { Button } from "./Button";
import { cn } from "./cn";
export function Navbar() {
const { t, i18n } = useTranslation();
const { user, logout, isAdmin } = useAuthStore();
const navigate = useNavigate();
const [langOpen, setLangOpen] = useState(false);
const handleLogout = () => {
logout();
navigate("/");
};
const changeLang = (lang: string) => {
i18n.changeLanguage(lang);
setLangOpen(false);
};
return (
<header className="border-b border-ink-800 bg-ink-950/80 backdrop-blur sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 text-ink-100 hover:text-accent-400 transition-colors">
<BookOpen size={20} className="text-accent-500" />
<span className="font-serif text-lg font-semibold">{t("app.title")}</span>
</Link>
<nav className="flex items-center gap-2">
{/* Language switcher */}
<div className="relative">
<button
onClick={() => setLangOpen(!langOpen)}
className="flex items-center gap-1 px-2 py-1.5 text-xs rounded text-ink-300 hover:text-ink-100 hover:bg-ink-800"
>
<Globe size={14} />
{i18n.language?.toUpperCase()}
</button>
{langOpen && (
<div className="absolute right-0 mt-1 w-24 bg-ink-900 border border-ink-700 rounded shadow-lg z-50">
{["ru", "en"].map((l) => (
<button
key={l}
onClick={() => changeLang(l)}
className={cn(
"block w-full text-left px-3 py-1.5 text-xs hover:bg-ink-800",
i18n.language === l ? "text-accent-400" : "text-ink-200"
)}
>
{l.toUpperCase()}
</button>
))}
</div>
)}
</div>
{user ? (
<>
<Link to="/dashboard">
<Button variant="ghost" size="sm">
{t("nav.dashboard")}
</Button>
</Link>
{isAdmin() && (
<Link to="/admin">
<Button variant="ghost" size="sm">
<Shield size={14} className="mr-1" />
{t("nav.admin")}
</Button>
</Link>
)}
<span className="text-xs text-ink-400 hidden sm:inline">{user.username}</span>
<Button variant="ghost" size="sm" onClick={handleLogout} title={t("nav.logout")}>
<LogOut size={14} />
</Button>
</>
) : (
<>
<Link to="/login">
<Button variant="ghost" size="sm">
{t("nav.login")}
</Button>
</Link>
<Link to="/register">
<Button variant="secondary" size="sm">
{t("nav.register")}
</Button>
</Link>
</>
)}
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,5 @@
export { Modal } from "./Modal";
export { Button } from "./Button";
export { Input, Textarea } from "./Input";
export { Card, CardBody, CardHeader } from "./Card";
export { cn } from "./cn";

View File

@@ -0,0 +1,104 @@
import { useTranslation } from "react-i18next";
interface CharacterSheetProps {
state: Record<string, any>;
}
export function CharacterSheet({ state }: CharacterSheetProps) {
const { t } = useTranslation();
if (!state || !state.player) {
return <p className="text-xs text-ink-500">{t("character.empty")}</p>;
}
const p = state.player;
const stats = p.stats || {};
return (
<div className="space-y-2 text-xs">
<div>
<span className="text-ink-400">{t("character.name")}: </span>
<span className="text-ink-100">{p.name}</span>
</div>
{(p.race || p.class) && (
<div className="text-ink-300">
{p.race} {p.class ? `· ${p.class}` : ""} {p.level ? `· Lvl ${p.level}` : ""}
</div>
)}
{p.location && (
<div>
<span className="text-ink-400">{t("character.location")}: </span>
<span className="text-ink-100">{p.location}</span>
</div>
)}
<div className="grid grid-cols-2 gap-1 mt-2">
<Stat label="HP" value={stats.health} max={stats.health_max} color="bg-red-500" />
<Stat label="MP" value={stats.mana} max={stats.mana_max} color="bg-blue-500" />
<Stat label="STA" value={stats.stamina} max={stats.stamina_max} color="bg-green-500" />
{p.gold !== undefined && (
<div className="text-ink-300">
<span className="text-ink-400">{t("character.gold")}: </span>
{p.gold}
</div>
)}
</div>
{(stats.strength || stats.dexterity || stats.intelligence) && (
<div className="mt-2 grid grid-cols-3 gap-1 text-center text-[10px]">
{["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"].map((s) =>
stats[s] !== undefined ? (
<div key={s} className="bg-ink-900 rounded p-1">
<div className="text-ink-500 uppercase">{s.slice(0, 3)}</div>
<div className="text-ink-100">{stats[s]}</div>
</div>
) : null
)}
</div>
)}
{p.inventory && p.inventory.length > 0 && (
<div className="mt-2">
<div className="text-ink-400 mb-1">{t("character.inventory")}</div>
<ul className="space-y-0.5">
{p.inventory.slice(0, 8).map((item: any, i: number) => (
<li key={i} className="text-ink-200">
{item.name}
{item.qty > 1 ? ` ×${item.qty}` : ""}
</li>
))}
{p.inventory.length > 8 && <li className="text-ink-500">+{p.inventory.length - 8}</li>}
</ul>
</div>
)}
{p.effects && p.effects.length > 0 && (
<div className="mt-2">
<div className="text-ink-400 mb-1">{t("character.effects")}</div>
<div className="flex flex-wrap gap-1">
{p.effects.map((e: any, i: number) => (
<span key={i} className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300">
{e.name || JSON.stringify(e)}
</span>
))}
</div>
</div>
)}
</div>
);
}
function Stat({ label, value, max, color }: { label: string; value?: number; max?: number; color: string }) {
if (value === undefined) return null;
const pct = max ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
return (
<div className="bg-ink-900 rounded p-1.5">
<div className="flex items-center justify-between mb-0.5">
<span className="text-ink-500">{label}</span>
<span className="text-ink-100">
{value}
{max ? `/${max}` : ""}
</span>
</div>
{max && (
<div className="h-1 rounded bg-ink-800 overflow-hidden">
<div className={`h-full ${color}`} style={{ width: `${pct}%` }} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal } from "@/components/ui/ui-overview";
import type { GlossaryEntry } from "@/types";
import { BookOpen } from "lucide-react";
interface GlossaryModalProps {
open: boolean;
onClose: () => void;
entries: GlossaryEntry[];
}
export function GlossaryModal({ open, onClose, entries }: GlossaryModalProps) {
const { t } = useTranslation();
const [filter, setFilter] = useState<string>("all");
const kinds = ["all", "npc", "location", "item", "lore", "event", "rule"];
const filtered = filter === "all" ? entries : entries.filter((e) => e.kind === filter);
return (
<Modal open={open} onClose={onClose} title={t("glossary.title")} size="lg">
<div className="flex flex-wrap gap-1 mb-4">
{kinds.map((k) => (
<button
key={k}
onClick={() => setFilter(k)}
className={`text-xs px-2.5 py-1 rounded ${
filter === k
? "bg-accent-500 text-white"
: "bg-ink-800 text-ink-300 hover:bg-ink-700"
}`}
>
{k === "all" ? "Все" : t(`glossary.${k}`)}
</button>
))}
</div>
{filtered.length === 0 ? (
<div className="text-center py-12">
<BookOpen className="mx-auto text-ink-600 mb-2" size={32} />
<p className="text-sm text-ink-400">{t("glossary.empty")}</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((e) => (
<div key={e.id} className="p-3 bg-ink-900 border border-ink-800 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-500/20 text-accent-400 uppercase">
{e.kind}
</span>
<h4 className="text-sm font-semibold text-ink-100">{e.name}</h4>
</div>
<p className="text-xs text-ink-300">{e.description}</p>
</div>
))}
</div>
)}
</Modal>
);
}

168
frontend/src/i18n/en.ts Normal file
View File

@@ -0,0 +1,168 @@
export const en = {
app: {
title: "AI RPG",
subtitle: "Flexible AI-powered role-playing game",
},
nav: {
home: "Home",
dashboard: "My worlds",
admin: "Admin",
logout: "Logout",
login: "Login",
register: "Register",
language: "Language",
},
auth: {
login_title: "Login",
register_title: "Register",
email: "Email",
username: "Username",
password: "Password",
login_btn: "Login",
register_btn: "Register",
no_account: "No account? Register",
have_account: "Have an account? Login",
admin_setup_title: "Admin setup",
admin_setup_desc: "Enter the token printed to the backend console to create the first admin user.",
admin_setup_token: "Setup token",
setup_btn: "Create admin",
},
worlds: {
title: "Your worlds",
new: "Create new world",
empty: "You don't have any worlds yet.",
preset_choice: "Pick a preset or start from scratch",
use_preset: "From preset",
from_scratch: "From scratch",
name: "World name",
language: "World language",
create: "Create",
cancel: "Cancel",
start: "Start game",
continue: "Continue",
edit: "Edit",
delete: "Delete",
builder_title: "World builder",
builder_desc: "Describe your world — the AI will help you build it out.",
setting_brief: "Setting",
setting_brief_ph: "Dark fantasy in a late-medieval style...",
character_brief: "Character",
character_brief_ph: "Young adventurer seeking fame...",
rules_brief: "World rules",
rules_brief_ph: "Mana-based magic system, d20 turn-based combat...",
notes: "Additional notes",
notes_ph: "I want a complex faction reputation system...",
start_builder: "Start dialogue with AI",
builder_history: "Dialogue history",
builder_message_ph: "Your reply or edits...",
send: "Send",
accept: "Accept world and create",
accepting: "Creating world...",
status_draft: "Draft",
status_ready: "Ready",
status_active: "Active",
status_archived: "Archived",
},
session: {
title: "Session",
back: "Back to worlds",
glossary: "Glossary",
character: "Character",
edit_world: "Edit world",
history: "History",
action_placeholder: "What do you do?",
send: "Act",
sending: "Thinking...",
options: "Options",
custom_action: "Or your own action",
status_planning: "Planning...",
status_orchestrator_turn: "GM turn...",
status_writing_scene: "Writing scene...",
world_time: "World time",
triggers_panel: "Deferred events",
no_messages: "Start with your first action!",
new_session: "New session",
error_iter: "Iteration failed",
},
admin: {
title: "Admin panel",
settings: "LLM settings",
base_url: "OpenAI-compatible endpoint",
api_key: "API key",
model: "Model",
temperature: "Temperature (orchestrator)",
step_temperature: "Temperature (step writer)",
summary_temperature: "Temperature (summarizer)",
max_tokens: "Max tokens",
request_timeout: "Timeout (sec)",
streaming: "Streaming",
context_settings: "Context manager",
recent_messages: "Guaranteed recent messages",
compress_threshold: "Compression threshold",
summary_messages: "Messages per summary",
max_tokens_total: "Context token budget",
trigger_settings: "Deferred triggers",
triggers_enabled: "Enabled",
triggers_check_interval: "Check interval (sec)",
embedding_settings: "Embeddings (RAG)",
embedding_provider: "Provider",
embedding_provider_hash: "Hash (offline fallback, no semantics)",
embedding_provider_openai: "OpenAI-compatible /embeddings",
embedding_base_url: "Endpoint (empty = same as LLM)",
embedding_api_key: "API key (empty = same as LLM)",
embedding_model: "Embedding model",
embedding_dim: "Dimension (0 = auto-probe)",
embedding_timeout: "Timeout (sec)",
embedding_test: "Test embeddings",
embedding_testing: "Testing...",
embedding_test_ok: "OK: {{provider}} | dim={{dim}} | norm={{norm}}",
embedding_test_fail: "Error: {{error}}",
save: "Save",
saved: "Saved!",
llm_logs: "LLM logs",
users: "Users",
back: "Back",
},
glossary: {
title: "World glossary",
npc: "NPCs",
location: "Locations",
item: "Items",
lore: "Lore",
event: "Events",
rule: "Rules",
empty: "Nothing yet. The AI will fill the glossary as you play.",
},
character: {
title: "Character sheet",
name: "Name",
race: "Race",
class: "Class",
level: "Level",
stats: "Stats",
inventory: "Inventory",
effects: "Effects",
gold: "Gold",
location: "Location",
empty: "No character data.",
},
common: {
save: "Save",
cancel: "Cancel",
delete: "Delete",
edit: "Edit",
confirm: "Confirm",
yes: "Yes",
no: "No",
loading: "Loading...",
error: "Error",
success: "Done",
not_found: "Not found",
forbidden: "Forbidden",
},
errors: {
network: "Network error. Check your connection.",
unauthorized: "Unauthorized.",
unknown: "Unknown error.",
},
};

View File

@@ -0,0 +1,24 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { ru } from "./ru";
import { en } from "./en";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
ru: { translation: ru },
en: { translation: en },
},
fallbackLng: "ru",
supportedLngs: ["ru", "en"],
interpolation: { escapeValue: false },
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
},
});
export default i18n;

168
frontend/src/i18n/ru.ts Normal file
View File

@@ -0,0 +1,168 @@
export const ru = {
app: {
title: "AI RPG",
subtitle: "Гибкая ролевая игра с ИИ",
},
nav: {
home: "Главная",
dashboard: "Мои миры",
admin: "Админка",
logout: "Выйти",
login: "Войти",
register: "Регистрация",
language: "Язык",
},
auth: {
login_title: "Вход",
register_title: "Регистрация",
email: "Email",
username: "Имя пользователя",
password: "Пароль",
login_btn: "Войти",
register_btn: "Зарегистрироваться",
no_account: "Нет аккаунта? Зарегистрируйтесь",
have_account: "Уже есть аккаунт? Войдите",
admin_setup_title: "Создание администратора",
admin_setup_desc: "Введите токен из консоли backend, чтобы создать первого администратора.",
admin_setup_token: "Токен инициализации",
setup_btn: "Создать администратора",
},
worlds: {
title: "Ваши миры",
new: "Создать новый мир",
empty: "У вас пока нет ни одного мира.",
preset_choice: "Выберите пресет или начните с нуля",
use_preset: "Из пресета",
from_scratch: "С нуля",
name: "Название мира",
language: "Язык мира",
create: "Создать",
cancel: "Отмена",
start: "Начать игру",
continue: "Продолжить",
edit: "Редактировать",
delete: "Удалить",
builder_title: "Конструктор мира",
builder_desc: "Опишите свой мир — ИИ поможет его достроить.",
setting_brief: "Сеттинг",
setting_brief_ph: "Тёмное фэнтези в духе позднего средневековья...",
character_brief: "Персонаж",
character_brief_ph: "Молодой авантюрист, ищущий славы...",
rules_brief: "Правила мира",
rules_brief_ph: "Система магии с маной, пошаговый бой d20...",
notes: "Дополнительные заметки",
notes_ph: "Хочу сложную систему репутации с фракциями...",
start_builder: "Начать диалог с ИИ",
builder_history: "История диалога",
builder_message_ph: "Ваш ответ или правки...",
send: "Отправить",
accept: "Принять мир и создать",
accepting: "Создаём мир...",
status_draft: "Черновик",
status_ready: "Готов",
status_active: "Активен",
status_archived: "В архиве",
},
session: {
title: "Сессия",
back: "Назад к мирам",
glossary: "Глоссарий",
character: "Персонаж",
edit_world: "Редактировать мир",
history: "История",
action_placeholder: "Что вы делаете?",
send: "Действие",
sending: "Думаю...",
options: "Варианты",
custom_action: "Или своё действие",
status_planning: "Планирование...",
status_orchestrator_turn: "Ход GM...",
status_writing_scene: "Пишем сцену...",
world_time: "Время мира",
triggers_panel: "Отложенные события",
no_messages: "Начните с первого действия!",
new_session: "Новая сессия",
error_iter: "Ошибка при выполнении итерации",
},
admin: {
title: "Панель администратора",
settings: "Настройки LLM",
base_url: "OpenAI-совместимый endpoint",
api_key: "API ключ",
model: "Модель",
temperature: "Temperature (orchestrator)",
step_temperature: "Temperature (step writer)",
summary_temperature: "Temperature (summarizer)",
max_tokens: "Max tokens",
request_timeout: "Таймаут (сек)",
streaming: "Стриминг",
context_settings: "Контекстный менеджер",
recent_messages: "Гарантированных сообщений",
compress_threshold: "Порог сжатия",
summary_messages: "Сообщений на сводку",
max_tokens_total: "Бюджет токенов контекста",
trigger_settings: "Отложенные триггеры",
triggers_enabled: "Включены",
triggers_check_interval: "Интервал проверки (сек)",
embedding_settings: "Эмбеддинги (RAG)",
embedding_provider: "Провайдер",
embedding_provider_hash: "Hash (офлайн-фолбэк, без семантики)",
embedding_provider_openai: "OpenAI-compatible /embeddings",
embedding_base_url: "Endpoint (пусто = как у LLM)",
embedding_api_key: "API ключ (пусто = как у LLM)",
embedding_model: "Модель эмбеддингов",
embedding_dim: "Размерность (0 = авто-проба)",
embedding_timeout: "Таймаут (сек)",
embedding_test: "Проверить эмбеддинги",
embedding_testing: "Проверяю...",
embedding_test_ok: "OK: {{provider}} | dim={{dim}} | norm={{norm}}",
embedding_test_fail: "Ошибка: {{error}}",
save: "Сохранить",
saved: "Сохранено!",
llm_logs: "Логи LLM",
users: "Пользователи",
back: "Назад",
},
glossary: {
title: "Глоссарий мира",
npc: "NPC",
location: "Локации",
item: "Предметы",
lore: "Лор",
event: "События",
rule: "Правила",
empty: "Пока пусто. ИИ заполнит глоссарий по ходу игры.",
},
character: {
title: "Лист персонажа",
name: "Имя",
race: "Раса",
class: "Класс",
level: "Уровень",
stats: "Характеристики",
inventory: "Инвентарь",
effects: "Эффекты",
gold: "Золото",
location: "Локация",
empty: "Нет данных о персонаже.",
},
common: {
save: "Сохранить",
cancel: "Отмена",
delete: "Удалить",
edit: "Редактировать",
confirm: "Подтвердить",
yes: "Да",
no: "Нет",
loading: "Загрузка...",
error: "Ошибка",
success: "Готово",
not_found: "Не найдено",
forbidden: "Доступ запрещён",
},
errors: {
network: "Сетевая ошибка. Проверьте подключение.",
unauthorized: "Не авторизован.",
unknown: "Неизвестная ошибка.",
},
};

59
frontend/src/index.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html, body, #root {
height: 100%;
}
body {
@apply bg-ink-950 text-ink-100 antialiased;
font-family: Inter, system-ui, sans-serif;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-ink-900;
}
::-webkit-scrollbar-thumb {
@apply bg-ink-700 rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-ink-600;
}
/* Narrative prose */
.prose-rpg {
@apply text-ink-100 leading-relaxed;
}
.prose-rpg p {
@apply mb-3;
}
.prose-rpg p:last-child {
@apply mb-0;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.pulse-soft {
animation: pulse-soft 1.5s ease-in-out infinite;
}

14
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
import "./i18n";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -0,0 +1,468 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { adminApi } from "@/api";
import type { LlmLog, SettingsOut } from "@/types";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
import { Save, ArrowLeft, Activity, Users, Zap } from "lucide-react";
export function AdminPanelPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [settings, setSettings] = useState<SettingsOut | null>(null);
const [values, setValues] = useState<Record<string, any>>({});
const [logs, setLogs] = useState<LlmLog[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [tab, setTab] = useState<"settings" | "logs" | "users">("settings");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const [embeddingTest, setEmbeddingTest] = useState<null | { ok: boolean; msg: string }>(null);
const [testingEmbeddings, setTestingEmbeddings] = useState(false);
useEffect(() => {
(async () => {
try {
const s = await adminApi.getSettings();
setSettings(s);
setValues(s.values);
const l = await adminApi.listLlmLogs(50);
setLogs(l);
const u = await adminApi.listUsers();
setUsers(u);
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
} finally {
setLoading(false);
}
})();
}, []);
const save = async () => {
setError("");
setSaving(true);
setSaved(false);
try {
// Strip masked api_key unless user typed a new one
const payload: Record<string, any> = { ...values };
for (const k of ["llm.api_key", "embedding.api_key"]) {
if (typeof payload[k] === "string" && payload[k].includes("***")) {
delete payload[k];
}
}
const s = await adminApi.updateSettings(payload);
setSettings(s);
setValues(s.values);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
} finally {
setSaving(false);
}
};
const testEmbeddings = async () => {
setError("");
setTestingEmbeddings(true);
setEmbeddingTest(null);
try {
// Build overrides from current form values (excluding masked api_key)
const overrides: Record<string, any> = {};
for (const k of [
"embedding.provider",
"embedding.base_url",
"embedding.api_key",
"embedding.model",
"embedding.dim",
"embedding.request_timeout",
"llm.base_url",
"llm.api_key",
]) {
const v = values[k];
if (v !== undefined && v !== null && !(typeof v === "string" && v.includes("***"))) {
overrides[k] = v;
}
}
const r = await adminApi.testEmbeddings(overrides);
if (r.ok) {
setEmbeddingTest({
ok: true,
msg: t("admin.embedding_test_ok", {
provider: r.provider,
dim: r.dim,
norm: r.sample_norm,
}),
});
} else {
setEmbeddingTest({ ok: false, msg: t("admin.embedding_test_fail", { error: r.error || "unknown" }) });
}
} catch (err: any) {
setEmbeddingTest({
ok: false,
msg: t("admin.embedding_test_fail", { error: err.response?.data?.detail || err.message }),
});
} finally {
setTestingEmbeddings(false);
}
};
if (loading) {
return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
}
return (
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-serif text-ink-100">{t("admin.title")}</h1>
</div>
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
<ArrowLeft size={14} className="mr-1" />
{t("admin.back")}
</Button>
</div>
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
{/* Tabs */}
<div className="flex gap-1 mb-4">
<TabButton active={tab === "settings"} onClick={() => setTab("settings")}>
{t("admin.settings")}
</TabButton>
<TabButton active={tab === "logs"} onClick={() => setTab("logs")}>
<Activity size={12} className="mr-1" />
{t("admin.llm_logs")}
</TabButton>
<TabButton active={tab === "users"} onClick={() => setTab("users")}>
<Users size={12} className="mr-1" />
{t("admin.users")}
</TabButton>
</div>
{tab === "settings" && settings && (
<div className="space-y-4">
<Card>
<CardHeader title={t("admin.settings")} />
<CardBody className="space-y-3">
<Input
label={t("admin.base_url")}
value={values["llm.base_url"] || ""}
onChange={(e) => setValues({ ...values, "llm.base_url": e.target.value })}
placeholder="http://localhost:1234/v1"
/>
<Input
label={t("admin.api_key")}
type="password"
value={values["llm.api_key"] || ""}
onChange={(e) => setValues({ ...values, "llm.api_key": e.target.value })}
placeholder="sk-..."
/>
<Input
label={t("admin.model")}
value={values["llm.model"] || ""}
onChange={(e) => setValues({ ...values, "llm.model": e.target.value })}
placeholder="local-model"
/>
<div className="grid grid-cols-2 gap-3">
<NumberInput
label={t("admin.temperature")}
value={values["llm.temperature"]}
onChange={(v) => setValues({ ...values, "llm.temperature": v })}
step={0.1}
/>
<NumberInput
label={t("admin.step_temperature")}
value={values["llm.step_temperature"]}
onChange={(v) => setValues({ ...values, "llm.step_temperature": v })}
step={0.1}
/>
<NumberInput
label={t("admin.summary_temperature")}
value={values["llm.summary_temperature"]}
onChange={(v) => setValues({ ...values, "llm.summary_temperature": v })}
step={0.1}
/>
<NumberInput
label={t("admin.max_tokens")}
value={values["llm.max_tokens"]}
onChange={(v) => setValues({ ...values, "llm.max_tokens": v })}
/>
<NumberInput
label={t("admin.request_timeout")}
value={values["llm.request_timeout"]}
onChange={(v) => setValues({ ...values, "llm.request_timeout": v })}
/>
<label className="flex items-center gap-2 text-xs text-ink-300 self-end mb-2">
<input
type="checkbox"
checked={!!values["llm.streaming"]}
onChange={(e) => setValues({ ...values, "llm.streaming": e.target.checked })}
className="rounded"
/>
{t("admin.streaming")}
</label>
</div>
</CardBody>
</Card>
{/* === Embeddings / RAG === */}
<Card>
<CardHeader
title={t("admin.embedding_settings")}
subtitle="hash = offline fallback · openai = real semantic search"
/>
<CardBody className="space-y-3">
<div>
<label className="block text-xs text-ink-400 mb-1">{t("admin.embedding_provider")}</label>
<select
className="w-full bg-ink-900 border border-ink-700 rounded px-2 py-1.5 text-sm text-ink-100"
value={values["embedding.provider"] || "hash"}
onChange={(e) => setValues({ ...values, "embedding.provider": e.target.value })}
>
<option value="hash">{t("admin.embedding_provider_hash")}</option>
<option value="openai">{t("admin.embedding_provider_openai")}</option>
</select>
</div>
<Input
label={t("admin.embedding_base_url")}
value={values["embedding.base_url"] || ""}
onChange={(e) => setValues({ ...values, "embedding.base_url": e.target.value })}
placeholder="http://localhost:1234/v1"
/>
<Input
label={t("admin.embedding_api_key")}
type="password"
value={values["embedding.api_key"] || ""}
onChange={(e) => setValues({ ...values, "embedding.api_key": e.target.value })}
placeholder="sk-... (пусто = как у LLM)"
/>
<div className="grid grid-cols-3 gap-3">
<Input
label={t("admin.embedding_model")}
value={values["embedding.model"] || ""}
onChange={(e) => setValues({ ...values, "embedding.model": e.target.value })}
placeholder="text-embedding-3-small"
/>
<NumberInput
label={t("admin.embedding_dim")}
value={values["embedding.dim"]}
onChange={(v) => setValues({ ...values, "embedding.dim": v })}
/>
<NumberInput
label={t("admin.embedding_timeout")}
value={values["embedding.request_timeout"]}
onChange={(v) => setValues({ ...values, "embedding.request_timeout": v })}
/>
</div>
<div className="flex items-center gap-2 pt-1">
<Button variant="ghost" onClick={testEmbeddings} disabled={testingEmbeddings}>
<Zap size={14} className="mr-1" />
{testingEmbeddings ? t("admin.embedding_testing") : t("admin.embedding_test")}
</Button>
{embeddingTest && (
<span className={`text-xs ${embeddingTest.ok ? "text-green-400" : "text-red-400"}`}>
{embeddingTest.msg}
</span>
)}
</div>
</CardBody>
</Card>
<Card>
<CardHeader title={t("admin.context_settings")} />
<CardBody>
<div className="grid grid-cols-2 gap-3">
<NumberInput
label={t("admin.recent_messages")}
value={values["context.recent_messages"]}
onChange={(v) => setValues({ ...values, "context.recent_messages": v })}
/>
<NumberInput
label={t("admin.compress_threshold")}
value={values["context.compress_threshold"]}
onChange={(v) => setValues({ ...values, "context.compress_threshold": v })}
/>
<NumberInput
label={t("admin.summary_messages")}
value={values["context.summary_messages"]}
onChange={(v) => setValues({ ...values, "context.summary_messages": v })}
/>
<NumberInput
label={t("admin.max_tokens_total")}
value={values["context.max_tokens_total"]}
onChange={(v) => setValues({ ...values, "context.max_tokens_total": v })}
/>
</div>
</CardBody>
</Card>
<Card>
<CardHeader title={t("admin.trigger_settings")} />
<CardBody>
<div className="grid grid-cols-2 gap-3 items-end">
<label className="flex items-center gap-2 text-xs text-ink-300">
<input
type="checkbox"
checked={!!values["triggers.enabled"]}
onChange={(e) => setValues({ ...values, "triggers.enabled": e.target.checked })}
/>
{t("admin.triggers_enabled")}
</label>
<NumberInput
label={t("admin.triggers_check_interval")}
value={values["triggers.check_interval"]}
onChange={(v) => setValues({ ...values, "triggers.check_interval": v })}
/>
</div>
</CardBody>
</Card>
<div className="flex justify-end gap-2">
{saved && <span className="text-sm text-green-400 self-center">{t("admin.saved")}</span>}
<Button onClick={save} disabled={saving}>
<Save size={14} className="mr-1" />
{saving ? t("common.loading") : t("admin.save")}
</Button>
</div>
</div>
)}
{tab === "logs" && (
<Card>
<CardHeader title={t("admin.llm_logs")} subtitle={`${logs.length} recent calls`} />
<CardBody>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-ink-400 border-b border-ink-800">
<th className="py-2 pr-3">Time</th>
<th className="py-2 pr-3">Purpose</th>
<th className="py-2 pr-3">Model</th>
<th className="py-2 pr-3 text-right">Tokens</th>
<th className="py-2 pr-3 text-right">Latency</th>
<th className="py-2">Status</th>
</tr>
</thead>
<tbody>
{logs.map((l) => (
<tr key={l.id} className="border-b border-ink-900 hover:bg-ink-900/50">
<td className="py-2 pr-3 text-ink-400">
{new Date(l.created_at).toLocaleTimeString()}
</td>
<td className="py-2 pr-3 text-ink-100">{l.purpose}</td>
<td className="py-2 pr-3 text-ink-300">{l.model}</td>
<td className="py-2 pr-3 text-right text-ink-300">
{l.total_tokens ?? "—"}
</td>
<td className="py-2 pr-3 text-right text-ink-300">
{l.latency_ms ? `${l.latency_ms}ms` : "—"}
</td>
<td className="py-2">
{l.error ? (
<span className="text-red-400">err</span>
) : (
<span className="text-green-400">ok</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardBody>
</Card>
)}
{tab === "users" && (
<Card>
<CardHeader title={t("admin.users")} subtitle={`${users.length} users`} />
<CardBody>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-ink-400 border-b border-ink-800">
<th className="py-2 pr-3">Email</th>
<th className="py-2 pr-3">Username</th>
<th className="py-2 pr-3">Role</th>
<th className="py-2 pr-3">Active</th>
<th className="py-2">Created</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-ink-900">
<td className="py-2 pr-3 text-ink-100">{u.email}</td>
<td className="py-2 pr-3 text-ink-300">{u.username}</td>
<td className="py-2 pr-3">
{u.is_admin ? (
<span className="text-accent-400">admin</span>
) : (
<span className="text-ink-400">user</span>
)}
</td>
<td className="py-2 pr-3">
{u.is_active ? (
<span className="text-green-400"></span>
) : (
<span className="text-red-400"></span>
)}
</td>
<td className="py-2 text-ink-400">
{new Date(u.created_at).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardBody>
</Card>
)}
</div>
);
}
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={`text-xs px-3 py-1.5 rounded-t border-b-2 transition-colors ${
active
? "border-accent-500 text-accent-400 bg-ink-900/50"
: "border-transparent text-ink-400 hover:text-ink-200"
}`}
>
{children}
</button>
);
}
function NumberInput({
label,
value,
onChange,
step,
}: {
label: string;
value: any;
onChange: (v: number) => void;
step?: number;
}) {
return (
<Input
label={label}
type="number"
step={step}
value={value ?? 0}
onChange={(e) => {
const v = parseFloat(e.target.value);
onChange(isNaN(v) ? 0 : v);
}}
/>
);
}

View File

@@ -0,0 +1,91 @@
import { FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Shield } from "lucide-react";
import { authApi } from "@/api";
import { useAuthStore } from "@/store/auth";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
export function AdminSetupPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [token, setToken] = useState("");
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const { access_token, user } = await authApi.adminSetup(token, email, username, password);
setAuth(access_token, user);
navigate("/admin");
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto px-4 py-12">
<div className="text-center mb-6">
<Shield className="mx-auto text-accent-500 mb-2" size={36} />
<h1 className="text-2xl font-serif text-ink-100">{t("auth.admin_setup_title")}</h1>
<p className="text-sm text-ink-400 mt-2">{t("auth.admin_setup_desc")}</p>
</div>
<Card>
<CardHeader title={t("auth.admin_setup_title")} />
<CardBody>
<form onSubmit={onSubmit} className="space-y-4">
<Input
label={t("auth.admin_setup_token")}
type="text"
name="token"
value={token}
onChange={(e) => setToken(e.target.value)}
required
placeholder="xxxxxxxxxxxxxxxx"
/>
<Input
label={t("auth.email")}
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
label={t("auth.username")}
type="text"
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<Input
label={t("auth.password")}
type="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<Button type="submit" disabled={loading} className="w-full">
{loading ? t("common.loading") : t("auth.setup_btn")}
</Button>
</form>
</CardBody>
</Card>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { Plus, BookOpen, Play, Pencil, Trash2 } from "lucide-react";
import { worldsApi, sessionsApi } from "@/api";
import type { World } from "@/types";
import { Button } from "@/components/ui/Button";
import { Card, CardBody } from "@/components/ui/Card";
export function DashboardPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [worlds, setWorlds] = useState<World[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = async () => {
try {
setLoading(true);
const ws = await worldsApi.list();
setWorlds(ws);
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleDelete = async (id: string) => {
if (!confirm(t("common.delete") + "?")) return;
try {
await worldsApi.delete(id);
await load();
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
}
};
const handlePlay = async (world: World) => {
try {
const session = await sessionsApi.create(world.id);
navigate(`/sessions/${session.id}`);
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
}
};
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-serif text-ink-100">{t("worlds.title")}</h1>
<Link to="/worlds/new">
<Button>
<Plus size={16} className="mr-1" />
{t("worlds.new")}
</Button>
</Link>
</div>
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
{loading ? (
<p className="text-ink-400">{t("common.loading")}</p>
) : worlds.length === 0 ? (
<Card>
<CardBody className="text-center py-12">
<BookOpen className="mx-auto text-ink-600 mb-3" size={32} />
<p className="text-ink-400 mb-4">{t("worlds.empty")}</p>
<Link to="/worlds/new">
<Button>
<Plus size={16} className="mr-1" />
{t("worlds.new")}
</Button>
</Link>
</CardBody>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{worlds.map((w) => (
<Card key={w.id}>
<CardBody>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-ink-100 mb-1">{w.name}</h3>
<div className="flex items-center gap-2 mb-2">
<StatusBadge status={w.status} t={t} />
<span className="text-xs text-ink-500">{w.language.toUpperCase()}</span>
{w.current_time && (
<span className="text-xs text-ink-500">· {w.current_time}</span>
)}
</div>
<p className="text-xs text-ink-400 line-clamp-2">
{w.definition?.setting_description?.slice(0, 160) || "—"}
</p>
</div>
</div>
<div className="flex gap-2 mt-4">
{w.status === "draft" ? (
<Link to={`/worlds/${w.id}/edit`}>
<Button size="sm" variant="secondary">
<Pencil size={12} className="mr-1" />
{t("worlds.edit")}
</Button>
</Link>
) : (
<Button size="sm" onClick={() => handlePlay(w)}>
<Play size={12} className="mr-1" />
{t("worlds.start")}
</Button>
)}
<Link to={`/worlds/${w.id}/edit`}>
<Button size="sm" variant="ghost">
<Pencil size={12} className="mr-1" />
{t("worlds.edit")}
</Button>
</Link>
<Button size="sm" variant="ghost" onClick={() => handleDelete(w.id)}>
<Trash2 size={12} className="mr-1" />
{t("worlds.delete")}
</Button>
</div>
</CardBody>
</Card>
))}
</div>
)}
</div>
);
}
function StatusBadge({ status, t }: { status: string; t: any }) {
const colors: Record<string, string> = {
draft: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
ready: "bg-blue-500/10 text-blue-400 border-blue-500/30",
active: "bg-green-500/10 text-green-400 border-green-500/30",
archived: "bg-ink-500/10 text-ink-400 border-ink-500/30",
};
const labels: Record<string, string> = {
draft: t("worlds.status_draft"),
ready: t("worlds.status_ready"),
active: t("worlds.status_active"),
archived: t("worlds.status_archived"),
};
return (
<span className={`text-xs px-2 py-0.5 rounded border ${colors[status] || colors.draft}`}>
{labels[status] || status}
</span>
);
}

View File

@@ -0,0 +1,76 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useAuthStore } from "@/store/auth";
import { Button } from "@/components/ui/Button";
import { BookOpen, Sparkles, Cog, Globe } from "lucide-react";
export function HomePage() {
const { t } = useTranslation();
const { user } = useAuthStore();
return (
<div className="max-w-5xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent-500/10 border border-accent-500/30 mb-4">
<BookOpen className="text-accent-500" size={32} />
</div>
<h1 className="text-4xl font-serif font-bold text-ink-100 mb-3">{t("app.title")}</h1>
<p className="text-ink-400 max-w-2xl mx-auto">{t("app.subtitle")}</p>
<div className="mt-6 flex gap-3 justify-center">
{user ? (
<Link to="/dashboard">
<Button size="lg">{t("nav.dashboard")}</Button>
</Link>
) : (
<>
<Link to="/login">
<Button size="lg" variant="primary">
{t("nav.login")}
</Button>
</Link>
<Link to="/register">
<Button size="lg" variant="secondary">
{t("nav.register")}
</Button>
</Link>
</>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-12">
<FeatureCard
icon={<Sparkles className="text-accent-500" size={24} />}
title={t("worlds.builder_title")}
desc={t("worlds.builder_desc")}
/>
<FeatureCard
icon={<Cog className="text-accent-500" size={24} />}
title={t("admin.settings")}
desc={t("admin.base_url")}
/>
<FeatureCard
icon={<Globe className="text-accent-500" size={24} />}
title={t("nav.language")}
desc="RU / EN"
/>
</div>
<div className="mt-12 text-center text-sm text-ink-500">
<Link to="/admin/setup" className="hover:text-accent-400 underline">
{t("auth.admin_setup_title")}
</Link>
</div>
</div>
);
}
function FeatureCard({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) {
return (
<div className="p-6 bg-ink-900/50 border border-ink-800 rounded-xl">
<div className="w-12 h-12 rounded-lg bg-ink-800 flex items-center justify-center mb-3">{icon}</div>
<h3 className="text-sm font-semibold text-ink-100 mb-2">{title}</h3>
<p className="text-xs text-ink-400">{desc}</p>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { authApi } from "@/api";
import { useAuthStore } from "@/store/auth";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card, CardBody } from "@/components/ui/Card";
export function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const { access_token, user } = await authApi.login(email, password);
setAuth(access_token, user);
navigate("/dashboard");
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto px-4 py-12">
<h1 className="text-2xl font-serif text-center text-ink-100 mb-6">{t("auth.login_title")}</h1>
<Card>
<CardBody>
<form onSubmit={onSubmit} className="space-y-4">
<Input
label={t("auth.email")}
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
<Input
label={t("auth.password")}
type="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<Button type="submit" disabled={loading} className="w-full">
{loading ? t("common.loading") : t("auth.login_btn")}
</Button>
</form>
<p className="text-center mt-4 text-sm text-ink-400">
<Link to="/register" className="text-accent-400 hover:underline">
{t("auth.no_account")}
</Link>
</p>
</CardBody>
</Card>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { authApi } from "@/api";
import { useAuthStore } from "@/store/auth";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card, CardBody } from "@/components/ui/Card";
export function RegisterPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const { access_token, user } = await authApi.register(email, username, password);
setAuth(access_token, user);
navigate("/dashboard");
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto px-4 py-12">
<h1 className="text-2xl font-serif text-center text-ink-100 mb-6">{t("auth.register_title")}</h1>
<Card>
<CardBody>
<form onSubmit={onSubmit} className="space-y-4">
<Input
label={t("auth.email")}
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
label={t("auth.username")}
type="text"
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<Input
label={t("auth.password")}
type="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<Button type="submit" disabled={loading} className="w-full">
{loading ? t("common.loading") : t("auth.register_btn")}
</Button>
</form>
<p className="text-center mt-4 text-sm text-ink-400">
<Link to="/login" className="text-accent-400 hover:underline">
{t("auth.have_account")}
</Link>
</p>
</CardBody>
</Card>
</div>
);
}

View File

@@ -0,0 +1,341 @@
import { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { fetchEventSource } from "@microsoft/fetch-event-source";
import { sessionsApi, worldsApi, miscApi } from "@/api";
import { useAuthStore } from "@/store/auth";
import type { Message, Session, World, GlossaryEntry, Trigger } from "@/types";
import { Button } from "@/components/ui/Button";
import { Textarea } from "@/components/ui/Input";
import { Card, CardBody } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal";
import { GlossaryModal } from "@/components/world/GlossaryModal";
import { CharacterSheet } from "@/components/world/CharacterSheet";
import { Send, BookOpen, User, Pencil, Clock, Zap, Loader2 } from "lucide-react";
import ReactMarkdown from "react-markdown";
export function SessionPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { token } = useAuthStore();
const [session, setSession] = useState<Session | null>(null);
const [world, setWorld] = useState<World | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [actionText, setActionText] = useState("");
const [loading, setLoading] = useState(true);
const [iterating, setIterating] = useState(false);
const [status, setStatus] = useState<string>("");
const [error, setError] = useState("");
const [glossaryOpen, setGlossaryOpen] = useState(false);
const [glossary, setGlossary] = useState<GlossaryEntry[]>([]);
const [triggers, setTriggers] = useState<Trigger[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const load = async () => {
if (!id) return;
try {
const s = await sessionsApi.get(id);
setSession(s);
const w = await worldsApi.get(s.world_id);
setWorld(w);
const msgs = await sessionsApi.listMessages(id);
setMessages(msgs);
const g = await miscApi.listGlossary(s.world_id);
setGlossary(g);
const tr = await miscApi.listTriggers(id, false);
setTriggers(tr);
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, [id]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, status]);
const runIteration = async () => {
if (!id || !actionText.trim() || iterating) return;
setError("");
setIterating(true);
setStatus(t("session.status_planning"));
const action = actionText.trim();
setActionText("");
// Optimistic: show user action immediately
const optimisticUserMsg: Message = {
id: `tmp-${Date.now()}`,
seq: messages.length + 1,
role: "user",
kind: "player_action",
content: action,
payload: {},
is_pinned: true,
hidden: false,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, optimisticUserMsg]);
try {
await fetchEventSource(`/api/sessions/${id}/iterate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ session_id: id, action_text: action }),
openWhenHidden: true,
onmessage(ev) {
const eventName = ev.event;
let data: any = {};
try {
data = JSON.parse(ev.data || "{}");
} catch {
data = {};
}
if (eventName === "status") {
const msg = data.message || "";
if (msg.startsWith("orchestrator")) setStatus(t("session.status_orchestrator_turn"));
else if (msg === "writing_scene") setStatus(t("session.status_writing_scene"));
else if (msg === "planning") setStatus(t("session.status_planning"));
else setStatus(msg);
} else if (eventName === "tool_call") {
setStatus(`🔧 ${data.name}(...)` );
} else if (eventName === "step_complete") {
const stepMsg: Message = {
id: data.message_id || `step-${Date.now()}`,
seq: data.seq || messages.length + 2,
role: "assistant",
kind: "narrative_step",
content: data.narrative || "",
payload: { options: data.options || [], world_time: data.world_time },
is_pinned: true,
hidden: false,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, stepMsg]);
if (world && data.world_time) {
setWorld({ ...world, current_time: data.world_time, state: data.state || world.state });
}
// Refresh triggers
if (id) {
miscApi.listTriggers(id, false).then(setTriggers).catch(() => {});
miscApi.listGlossary(world?.id || "").then(setGlossary).catch(() => {});
}
} else if (eventName === "error") {
setError(data.message || t("session.error_iter"));
} else if (eventName === "done") {
// Final reload to get fresh seq/order
load();
}
},
onclose() {
setIterating(false);
setStatus("");
},
onerror(err) {
setIterating(false);
setStatus("");
setError(String(err) || t("session.error_iter"));
throw err; // stop retry
},
});
} catch (err: any) {
setIterating(false);
setStatus("");
setError(err.message || t("session.error_iter"));
}
};
const pickOption = (opt: string) => {
setActionText(opt);
};
if (loading) {
return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
}
if (!session || !world) {
return <div className="p-8 text-center text-red-400">{error || t("common.not_found")}</div>;
}
const lastNarrative = [...messages].reverse().find((m) => m.kind === "narrative_step");
const options: string[] = lastNarrative?.payload?.options || [];
return (
<div className="h-[calc(100vh-3.5rem)] flex flex-col">
{/* Top bar */}
<div className="border-b border-ink-800 bg-ink-950/50 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<Button size="sm" variant="ghost" onClick={() => navigate("/dashboard")}>
{t("session.back")}
</Button>
<div className="min-w-0">
<div className="text-sm font-medium text-ink-100 truncate">{session.title}</div>
<div className="text-xs text-ink-500 truncate flex items-center gap-1">
<Clock size={10} />
{world.current_time || "—"}
</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => setGlossaryOpen(true)}>
<BookOpen size={14} className="mr-1" />
{t("session.glossary")}
</Button>
<Button size="sm" variant="ghost" onClick={() => navigate(`/worlds/${world.id}/edit`)}>
<Pencil size={14} className="mr-1" />
{t("session.edit_world")}
</Button>
</div>
</div>
{/* Main area: chat + sidebar */}
<div className="flex-1 flex min-h-0">
{/* Chat */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-4">
{messages.length === 0 && !iterating && (
<div className="text-center text-ink-500 py-12">{t("session.no_messages")}</div>
)}
{messages
.filter((m) => !m.hidden)
.map((m, i) => (
<MessageBubble key={m.id || i} message={m} />
))}
{iterating && (
<div className="flex items-center gap-2 text-ink-400 text-sm fade-in">
<Loader2 size={14} className="animate-spin" />
{status || t("session.sending")}
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Options + input */}
<div className="border-t border-ink-800 bg-ink-950/50 p-3 space-y-2">
{options.length > 0 && !iterating && (
<div className="flex flex-wrap gap-2">
{options.map((opt, i) => (
<button
key={i}
onClick={() => pickOption(opt)}
className="text-xs px-3 py-1.5 rounded-lg bg-ink-800 hover:bg-ink-700 border border-ink-700 text-ink-200"
>
{opt}
</button>
))}
</div>
)}
<div className="flex gap-2">
<Textarea
value={actionText}
onChange={(e) => setActionText(e.target.value)}
placeholder={t("session.action_placeholder")}
disabled={iterating}
rows={2}
className="min-h-[44px]"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
runIteration();
}
}}
/>
<Button onClick={runIteration} disabled={iterating || !actionText.trim()} className="self-end">
<Send size={14} className="mr-1" />
{iterating ? t("session.sending") : t("session.send")}
</Button>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
</div>
{/* Sidebar: character sheet + triggers */}
<aside className="w-72 border-l border-ink-800 bg-ink-950/50 hidden lg:flex flex-col">
<div className="p-3 border-b border-ink-800">
<div className="flex items-center gap-2 text-xs font-medium text-ink-300 mb-2">
<User size={12} />
{t("session.character")}
</div>
<CharacterSheet state={world.state} />
</div>
<div className="p-3 flex-1 overflow-y-auto">
<div className="flex items-center gap-2 text-xs font-medium text-ink-300 mb-2">
<Zap size={12} />
{t("session.triggers_panel")}
</div>
{triggers.length === 0 ? (
<p className="text-xs text-ink-500"></p>
) : (
<div className="space-y-2">
{triggers.map((tr) => (
<div key={tr.id} className="text-xs p-2 rounded bg-ink-900 border border-ink-800">
<div className="text-ink-300">{tr.description}</div>
<div className="text-ink-500 mt-1"> {tr.fire_at}</div>
</div>
))}
</div>
)}
</div>
</aside>
</div>
<GlossaryModal open={glossaryOpen} onClose={() => setGlossaryOpen(false)} entries={glossary} />
</div>
);
}
function MessageBubble({ message }: { message: Message }) {
if (message.kind === "player_action") {
return (
<div className="flex justify-end fade-in">
<div className="max-w-[80%] bg-accent-500/20 border border-accent-500/40 rounded-xl p-3">
<div className="text-xs text-accent-300 mb-1">Игрок</div>
<div className="text-sm text-ink-100 whitespace-pre-wrap">{message.content}</div>
</div>
</div>
);
}
if (message.kind === "narrative_step") {
const content = message.content.startsWith("[Событие]")
? message.content
: message.content;
return (
<div className="fade-in">
<div className="bg-ink-900/70 border border-ink-800 rounded-xl p-4">
<div className="prose-rpg text-sm">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
{message.payload?.options?.length > 0 && (
<div className="mt-3 pt-3 border-t border-ink-800 flex flex-wrap gap-2">
{message.payload.options.map((opt: string, i: number) => (
<span key={i} className="text-xs px-2 py-1 rounded bg-ink-800 text-ink-300">
{opt}
</span>
))}
</div>
)}
</div>
</div>
);
}
if (message.kind === "summary") {
return (
<div className="text-xs text-ink-500 italic text-center px-4 py-2 border-y border-ink-800/50">
📜 {message.content}
</div>
);
}
return null;
}

View File

@@ -0,0 +1,266 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { worldsApi } from "@/api";
import type { WorldBuilderReply } from "@/types";
import { Button } from "@/components/ui/Button";
import { Input, Textarea } from "@/components/ui/Input";
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
import { Send, Check, Sparkles } from "lucide-react";
interface BuilderMessage {
role: "user" | "assistant";
text: string;
proposed?: any;
is_final?: boolean;
followups?: string[];
}
export function WorldBuilderPage() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const location = useLocation() as any;
const presetId = location.state?.presetId as string | undefined;
const fromScratch = location.state?.fromScratch as boolean | undefined;
const [worldName, setWorldName] = useState("");
const [language, setLanguage] = useState(i18n.language === "en" ? "en" : "ru");
const [settingBrief, setSettingBrief] = useState("");
const [characterBrief, setCharacterBrief] = useState("");
const [rulesBrief, setRulesBrief] = useState("");
const [notes, setNotes] = useState("");
const [started, setStarted] = useState(false);
const [loading, setLoading] = useState(false);
const [committing, setCommitting] = useState(false);
const [error, setError] = useState("");
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<BuilderMessage[]>([]);
const [currentReply, setCurrentReply] = useState<WorldBuilderReply | null>(null);
const [userInput, setUserInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, currentReply]);
const startBuilder = async (e: FormEvent) => {
e.preventDefault();
if (!worldName.trim()) return;
setError("");
setLoading(true);
try {
const reply = await worldsApi.builderStart({
world_name: worldName,
language,
preset_id: presetId,
setting_brief: settingBrief,
character_brief: characterBrief,
rules_brief: rulesBrief,
notes,
});
setSessionId(reply.session_id);
setMessages([{ role: "assistant", text: reply.ai_message, proposed: reply.proposed_definition, is_final: reply.is_final, followups: reply.followup_questions }]);
setCurrentReply(reply);
setStarted(true);
} catch (err: any) {
setError(err.response?.data?.detail || err.message || t("errors.unknown"));
} finally {
setLoading(false);
}
};
const sendMessage = async (e: FormEvent) => {
e.preventDefault();
if (!userInput.trim() || !sessionId) return;
setError("");
const msg = userInput.trim();
setUserInput("");
setMessages((prev) => [...prev, { role: "user", text: msg }]);
setLoading(true);
try {
const reply = await worldsApi.builderContinue(sessionId, msg);
setMessages((prev) => [
...prev,
{
role: "assistant",
text: reply.ai_message,
proposed: reply.proposed_definition,
is_final: reply.is_final,
followups: reply.followup_questions,
},
]);
setCurrentReply(reply);
} catch (err: any) {
setError(err.response?.data?.detail || err.message || t("errors.unknown"));
} finally {
setLoading(false);
}
};
const commitWorld = async () => {
if (!sessionId) return;
setError("");
setCommitting(true);
try {
const world = await worldsApi.builderCommit(sessionId, worldName);
navigate(`/worlds/${world.id}/edit`);
} catch (err: any) {
setError(err.response?.data?.detail || err.message || t("errors.unknown"));
} finally {
setCommitting(false);
}
};
if (!started) {
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<h1 className="text-2xl font-serif text-ink-100 mb-6">{t("worlds.builder_title")}</h1>
<Card>
<CardHeader title={t("worlds.builder_title")} subtitle={t("worlds.builder_desc")} />
<CardBody>
<form onSubmit={startBuilder} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="md:col-span-2">
<Input
label={t("worlds.name")}
value={worldName}
onChange={(e) => setWorldName(e.target.value)}
required
placeholder="Тёмное Королевство"
/>
</div>
<div>
<label className="block text-xs font-medium text-ink-300 mb-1">{t("worlds.language")}</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-ink-900 border border-ink-700 text-ink-100"
>
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
</div>
<Textarea
label={t("worlds.setting_brief")}
value={settingBrief}
onChange={(e) => setSettingBrief(e.target.value)}
placeholder={t("worlds.setting_brief_ph")}
rows={3}
/>
<Textarea
label={t("worlds.character_brief")}
value={characterBrief}
onChange={(e) => setCharacterBrief(e.target.value)}
placeholder={t("worlds.character_brief_ph")}
rows={2}
/>
<Textarea
label={t("worlds.rules_brief")}
value={rulesBrief}
onChange={(e) => setRulesBrief(e.target.value)}
placeholder={t("worlds.rules_brief_ph")}
rows={2}
/>
<Textarea
label={t("worlds.notes")}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("worlds.notes_ph")}
rows={2}
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<Button type="submit" disabled={loading}>
<Sparkles size={14} className="mr-1" />
{loading ? t("common.loading") : t("worlds.start_builder")}
</Button>
</form>
</CardBody>
</Card>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 py-6 h-[calc(100vh-3.5rem)] flex flex-col">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-serif text-ink-100">{t("worlds.builder_title")} {worldName}</h1>
<Button size="sm" variant="ghost" onClick={() => navigate("/dashboard")}>
{t("worlds.cancel")}
</Button>
</div>
<Card className="flex-1 flex flex-col min-h-0">
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((m, i) => (
<div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-xl p-3 ${
m.role === "user"
? "bg-accent-500/20 border border-accent-500/40 text-ink-100"
: "bg-ink-800 border border-ink-700 text-ink-100"
}`}
>
<div className="text-xs text-ink-400 mb-1">
{m.role === "user" ? "Вы" : "ИИ"}
</div>
<div className="whitespace-pre-wrap text-sm">{m.text}</div>
{m.followups && m.followups.length > 0 && (
<ul className="mt-2 text-xs text-ink-300 list-disc list-inside">
{m.followups.map((q, qi) => (
<li key={qi}>{q}</li>
))}
</ul>
)}
{m.proposed && (
<details className="mt-2 text-xs">
<summary className="cursor-pointer text-accent-400">
Предложенное определение мира
</summary>
<pre className="mt-1 p-2 bg-ink-900 rounded text-[10px] overflow-x-auto">
{JSON.stringify(m.proposed, null, 2)}
</pre>
</details>
)}
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-ink-800 border border-ink-700 rounded-xl p-3 text-ink-400 text-sm pulse-soft">
{t("common.loading")}
</div>
</div>
)}
</div>
{error && <div className="px-4 py-2 text-sm text-red-400 border-t border-ink-800">{error}</div>}
<div className="p-3 border-t border-ink-800 flex gap-2">
<form onSubmit={sendMessage} className="flex-1 flex gap-2">
<Input
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
placeholder={t("worlds.builder_message_ph")}
disabled={loading}
/>
<Button type="submit" disabled={loading || !userInput.trim()}>
<Send size={14} />
</Button>
</form>
<Button
variant="secondary"
onClick={commitWorld}
disabled={committing || !currentReply?.proposed_definition}
title={!currentReply?.proposed_definition ? "ИИ ещё не предложил мир" : ""}
>
<Check size={14} className="mr-1" />
{committing ? t("worlds.accepting") : t("worlds.accept")}
</Button>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { presetsApi } from "@/api";
import type { Preset } from "@/types";
import { Button } from "@/components/ui/Button";
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
import { Sparkles, FileText } from "lucide-react";
export function WorldCreatePage() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [presets, setPresets] = useState<Preset[]>([]);
const [loading, setLoading] = useState(true);
const [mode, setMode] = useState<"preset" | "scratch" | null>(null);
const [selectedPreset, setSelectedPreset] = useState<Preset | null>(null);
useEffect(() => {
(async () => {
try {
const p = await presetsApi.list(i18n.language);
setPresets(p);
} catch (e) {
// ignore
} finally {
setLoading(false);
}
})();
}, [i18n.language]);
const startBuilder = () => {
// Pass state via location state to WorldBuilderPage
navigate("/worlds/builder", {
state: {
presetId: mode === "preset" ? selectedPreset?.id : undefined,
fromScratch: mode === "scratch",
},
});
};
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<h1 className="text-2xl font-serif text-ink-100 mb-6">{t("worlds.preset_choice")}</h1>
{!mode && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button onClick={() => setMode("preset")} className="text-left">
<Card className="hover:border-accent-500 transition-colors cursor-pointer h-full">
<CardBody>
<Sparkles className="text-accent-500 mb-3" size={24} />
<h3 className="font-semibold text-ink-100 mb-2">{t("worlds.use_preset")}</h3>
<p className="text-xs text-ink-400">
{t("worlds.preset_choice")} Fantasy, ...
</p>
</CardBody>
</Card>
</button>
<button onClick={() => setMode("scratch")} className="text-left">
<Card className="hover:border-accent-500 transition-colors cursor-pointer h-full">
<CardBody>
<FileText className="text-accent-500 mb-3" size={24} />
<h3 className="font-semibold text-ink-100 mb-2">{t("worlds.from_scratch")}</h3>
<p className="text-xs text-ink-400">{t("worlds.builder_desc")}</p>
</CardBody>
</Card>
</button>
</div>
)}
{mode === "preset" && (
<Card>
<CardHeader
title={t("worlds.use_preset")}
action={
<Button size="sm" variant="ghost" onClick={() => setMode(null)}>
{t("worlds.cancel")}
</Button>
}
/>
<CardBody>
{loading ? (
<p className="text-ink-400">{t("common.loading")}</p>
) : (
<div className="space-y-2">
{presets.map((p) => (
<button
key={p.id}
onClick={() => setSelectedPreset(p)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
selectedPreset?.id === p.id
? "border-accent-500 bg-accent-500/10"
: "border-ink-700 hover:border-ink-600 bg-ink-900"
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-ink-100">{p.title}</div>
<div className="text-xs text-ink-400 mt-0.5">{p.description}</div>
</div>
<div className="flex gap-2 text-xs text-ink-500">
<span>{p.language.toUpperCase()}</span>
{p.is_builtin && (
<span className="px-1.5 py-0.5 bg-accent-500/20 text-accent-400 rounded">
built-in
</span>
)}
</div>
</div>
</button>
))}
</div>
)}
<div className="flex justify-end gap-2 mt-4">
<Button variant="ghost" onClick={() => setSelectedPreset(null)}>
{t("worlds.cancel")}
</Button>
<Button disabled={!selectedPreset} onClick={startBuilder}>
{t("worlds.start_builder")}
</Button>
</div>
</CardBody>
</Card>
)}
{mode === "scratch" && (
<Card>
<CardHeader
title={t("worlds.from_scratch")}
action={
<Button size="sm" variant="ghost" onClick={() => setMode(null)}>
{t("worlds.cancel")}
</Button>
}
/>
<CardBody>
<p className="text-sm text-ink-400 mb-4">{t("worlds.builder_desc")}</p>
<Button onClick={startBuilder}>{t("worlds.start_builder")}</Button>
</CardBody>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { worldsApi, sessionsApi } from "@/api";
import type { World } from "@/types";
import { Button } from "@/components/ui/Button";
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
import { Play } from "lucide-react";
export function WorldEditPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [world, setWorld] = useState<World | null>(null);
const [definitionText, setDefinitionText] = useState("");
const [stateText, setStateText] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
(async () => {
if (!id) return;
try {
const w = await worldsApi.get(id);
setWorld(w);
setDefinitionText(JSON.stringify(w.definition, null, 2));
setStateText(JSON.stringify(w.state, null, 2));
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
} finally {
setLoading(false);
}
})();
}, [id]);
const save = async () => {
if (!id || !world) return;
setError("");
setSaving(true);
try {
const definition = JSON.parse(definitionText);
const state = JSON.parse(stateText);
const updated = await worldsApi.update(id, {
definition,
state,
current_time: world.current_time,
status: world.status === "draft" ? "ready" : world.status,
});
setWorld(updated);
} catch (err: any) {
setError(err.message || t("errors.unknown"));
} finally {
setSaving(false);
}
};
const startSession = async () => {
if (!id) return;
try {
const session = await sessionsApi.create(id);
navigate(`/sessions/${session.id}`);
} catch (err: any) {
setError(err.response?.data?.detail || t("errors.unknown"));
}
};
if (loading) return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
if (!world) return <div className="p-8 text-center text-red-400">{error || t("common.not_found")}</div>;
return (
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-serif text-ink-100">
{t("worlds.edit")}: {world.name}
</h1>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
{t("session.back")}
</Button>
<Button onClick={startSession} disabled={world.status === "draft"}>
<Play size={14} className="mr-1" />
{t("worlds.start")}
</Button>
</div>
</div>
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader title="World definition (JSON)" subtitle="setting, rules, schema, plot_rails, initial_state" />
<CardBody>
<textarea
value={definitionText}
onChange={(e) => setDefinitionText(e.target.value)}
className="w-full h-[60vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
spellCheck={false}
/>
</CardBody>
</Card>
<Card>
<CardHeader title="Live world state (JSON)" subtitle="current player/NPC/inventory/time" />
<CardBody>
<textarea
value={stateText}
onChange={(e) => setStateText(e.target.value)}
className="w-full h-[60vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
spellCheck={false}
/>
</CardBody>
</Card>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
{t("worlds.cancel")}
</Button>
<Button onClick={save} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { User } from "@/types";
interface AuthState {
token: string | null;
user: User | null;
setAuth: (token: string, user: User) => void;
logout: () => void;
isAdmin: () => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
token: null,
user: null,
setAuth: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }),
isAdmin: () => !!get().user?.is_admin,
}),
{ name: "ai-rpg-auth" }
)
);

131
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,131 @@
export interface User {
id: string;
email: string;
username: string;
is_admin: boolean;
is_active: boolean;
preferred_language: string;
created_at: string;
}
export interface TokenOut {
access_token: string;
token_type: string;
user: User;
}
export interface Preset {
id: string;
slug: string;
title: string;
description: string | null;
language: string;
is_public: boolean;
is_builtin: boolean;
payload: Record<string, any>;
created_at: string;
}
export interface WorldDefinition {
setting_description: string;
rules: Record<string, any>;
world_schema: Record<string, any>;
plot_rails: Record<string, any>;
initial_state: Record<string, any>;
initial_time: string | null;
}
export interface World {
id: string;
owner_id: string;
name: string;
language: string;
definition: Record<string, any>;
state: Record<string, any>;
current_time: string | null;
status: "draft" | "ready" | "active" | "archived";
preset_id: string | null;
created_at: string;
updated_at: string;
}
export interface Session {
id: string;
world_id: string;
title: string;
is_active: boolean;
created_at: string;
last_played_at: string | null;
}
export interface Message {
id: string;
seq: number;
role: string;
kind: string;
content: string;
payload: Record<string, any>;
is_pinned: boolean;
hidden: boolean;
created_at: string;
}
export interface GlossaryEntry {
id: string;
kind: string;
name: string;
description: string;
payload: Record<string, any>;
}
export interface Trigger {
id: string;
session_id: string;
fire_at: string;
description: string;
payload: Record<string, any>;
fired: boolean;
created_at: string;
}
export interface SettingsOut {
values: Record<string, any>;
editable_keys: string[];
}
export interface LlmLog {
id: string;
purpose: string;
model: string;
base_url: string;
prompt_tokens: number | null;
completion_tokens: number | null;
total_tokens: number | null;
latency_ms: number | null;
error: string | null;
created_at: string;
}
export interface WorldBuilderReply {
session_id: string;
turn: number;
ai_message: string;
proposed_definition: WorldDefinition | null;
is_final: boolean;
followup_questions: string[];
}
export type IterationEventType =
| "status"
| "plan"
| "tool_call"
| "tool_result"
| "narrative_chunk"
| "step_complete"
| "error"
| "done";
export interface IterationEvent {
type: IterationEventType;
data: Record<string, any>;
}