rebase
This commit is contained in:
@@ -1,102 +1,160 @@
|
||||
import { useEffect } from "react";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useUiStore } from "@/store/ui";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { Navbar } from "@/components/ui/Navbar";
|
||||
import { HomePage } from "@/pages/HomePage";
|
||||
import { ToastViewport } from "@/components/ui/Toast";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
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 { AdminRegisterPage } from "@/pages/AdminRegisterPage";
|
||||
import { WorldsListPage } from "@/pages/WorldsListPage";
|
||||
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() {
|
||||
// Load public UI settings (logo URL, etc.) once on app boot. These are
|
||||
// unauthenticated and cached by the api layer, so subsequent navigations
|
||||
// do not re-fetch. The admin panel calls load(true) after saving to
|
||||
// pick up a new logo URL without a full page reload.
|
||||
const loadUi = useUiStore((s) => s.load);
|
||||
useEffect(() => {
|
||||
loadUi();
|
||||
}, [loadUi]);
|
||||
import { PlayPage } from "@/pages/PlayPage";
|
||||
import { AdminPage } from "@/pages/AdminPage";
|
||||
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="min-h-screen bg-bg text-fg">
|
||||
<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>
|
||||
{children}
|
||||
<ToastViewport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RootRedirect() {
|
||||
const status = useAuthStore((s) => s.status);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (status === "authenticated" && user) return <Navigate to="/worlds" replace />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
function PublicOnly({ children }: { children: React.ReactNode }) {
|
||||
const status = useAuthStore((s) => s.status);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const location = useLocation();
|
||||
if (status === "authenticated" && user) {
|
||||
const from = (location.state as { from?: string } | null)?.from;
|
||||
return <Navigate to={from || "/worlds"} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
document.title = t("common.app_name");
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
|
||||
{/* Public auth routes — full layout but no navbar redirect */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicOnly>
|
||||
<Layout>
|
||||
<LoginPage />
|
||||
</Layout>
|
||||
</PublicOnly>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicOnly>
|
||||
<Layout>
|
||||
<RegisterPage />
|
||||
</Layout>
|
||||
</PublicOnly>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register/admin"
|
||||
element={
|
||||
<PublicOnly>
|
||||
<Layout>
|
||||
<AdminRegisterPage />
|
||||
</Layout>
|
||||
</PublicOnly>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/worlds"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<WorldsListPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/worlds/new"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<WorldBuilderPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/worlds/:id/edit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<WorldEditPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/worlds/:id/play"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<PlayPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<Layout>
|
||||
<AdminPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
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 (login: string, password: string): Promise<TokenOut> => {
|
||||
// `login` accepts either email or username.
|
||||
const { data } = await api.post("/auth/login", { login, 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;
|
||||
},
|
||||
testLlm: async (overrides?: Record<string, any>): Promise<{
|
||||
ok: boolean;
|
||||
base_url: string;
|
||||
model: string;
|
||||
dns_resolved?: boolean;
|
||||
resolved_addrs?: string[];
|
||||
tcp_connect_ok?: boolean;
|
||||
models_endpoint_status?: number;
|
||||
available_models?: string[];
|
||||
chat_endpoint_status?: number;
|
||||
latency_ms?: number;
|
||||
response_preview?: string;
|
||||
usage?: any;
|
||||
error?: string;
|
||||
error_type?: string;
|
||||
}> => {
|
||||
const { data } = await api.post("/admin/llm/test", { overrides: overrides || {} });
|
||||
return data;
|
||||
},
|
||||
testLlmTools: async (overrides?: Record<string, any>): Promise<{
|
||||
ok: boolean;
|
||||
base_url: string;
|
||||
model: string;
|
||||
tool_calls_returned: boolean;
|
||||
tool_call_name?: string;
|
||||
tool_call_args?: any;
|
||||
text?: string;
|
||||
raw_tool_calls?: any[];
|
||||
http_status?: number;
|
||||
latency_ms?: number;
|
||||
usage?: any;
|
||||
error?: string;
|
||||
error_type?: string;
|
||||
}> => {
|
||||
const { data } = await api.post("/admin/llm/test-tools", { 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;
|
||||
},
|
||||
setUserActive: async (userId: string, isActive: boolean): Promise<any> => {
|
||||
const { data } = await api.post(`/admin/users/${userId}/set-active`, { is_active: isActive });
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
// Public UI settings — no auth required. Used on login/register/home pages
|
||||
// to render branding (logo, eventually theme). Caches the result in-process
|
||||
// so multiple components can call getPublicSettings() without re-fetching.
|
||||
export type PublicUiSettings = {
|
||||
logo_url?: string;
|
||||
};
|
||||
|
||||
let _publicSettingsCache: PublicUiSettings | null = null;
|
||||
let _publicSettingsPromise: Promise<PublicUiSettings> | null = null;
|
||||
|
||||
export const uiApi = {
|
||||
/** Fetch public UI settings (logo URL, etc.). Cached after first call. */
|
||||
getPublicSettings: async (force = false): Promise<PublicUiSettings> => {
|
||||
if (_publicSettingsCache && !force) return _publicSettingsCache;
|
||||
if (_publicSettingsPromise && !force) return _publicSettingsPromise;
|
||||
_publicSettingsPromise = (async () => {
|
||||
try {
|
||||
const { data } = await api.get("/settings/public");
|
||||
_publicSettingsCache = {
|
||||
logo_url: data["ui.logo_url"] || "/logo.png",
|
||||
};
|
||||
} catch {
|
||||
_publicSettingsCache = { logo_url: "/logo.png" };
|
||||
} finally {
|
||||
_publicSettingsPromise = null;
|
||||
}
|
||||
return _publicSettingsCache;
|
||||
})();
|
||||
return _publicSettingsPromise;
|
||||
},
|
||||
/** Reset the in-memory cache. Call after admin saves new ui.logo_url. */
|
||||
resetCache: () => {
|
||||
_publicSettingsCache = null;
|
||||
_publicSettingsPromise = null;
|
||||
},
|
||||
};
|
||||
|
||||
export const SSE_ENDPOINT = "/api/sessions";
|
||||
78
frontend/src/components/admin/IconsPanel.tsx
Normal file
78
frontend/src/components/admin/IconsPanel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { UploadIconResult } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
type IconKind = "favicon" | "logo" | "og_image";
|
||||
|
||||
export function IconsPanel() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<IconUploadCard kind="favicon" title={t("admin.icons_favicon")} />
|
||||
<IconUploadCard kind="logo" title={t("admin.icons_logo")} />
|
||||
<IconUploadCard kind="og_image" title={t("admin.icons_og")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconUploadCard({ kind, title }: { kind: IconKind; title: string }) {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<UploadIconResult | null>(null);
|
||||
|
||||
const onFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const r = await AdminApi.uploadIcon(file, kind);
|
||||
setResult(r);
|
||||
pushToast("success", t("admin.icons_uploaded"));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : t("admin.icons_upload_failed"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={title}>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void onFile(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" onClick={() => fileRef.current?.click()} disabled={uploading}>
|
||||
{t("admin.choose_file")}
|
||||
</Button>
|
||||
{uploading && <Spinner size="sm" />}
|
||||
</div>
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-xs">
|
||||
{result.url && (
|
||||
<p className="text-fg-muted">
|
||||
URL: <a className="text-accent underline" href={result.url} target="_blank" rel="noreferrer">{result.url}</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-fg-muted">Size: {result.size_bytes} bytes</p>
|
||||
{result.url && (kind === "favicon" || kind === "logo" || kind === "og_image") && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={result.url} alt={kind} className="mt-2 max-h-24 rounded border border-fg-dim/30" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
256
frontend/src/components/admin/LlmLogsTable.tsx
Normal file
256
frontend/src/components/admin/LlmLogsTable.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { LlmLog, LlmLogDetail, Paginated } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
export function LlmLogsTable() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const [data, setData] = useState<Paginated<LlmLog> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({ world_id: "", stage: "", status_filter: "" });
|
||||
const [appliedFilters, setAppliedFilters] = useState(filters);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage] = useState(20);
|
||||
const [detail, setDetail] = useState<LlmLogDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await AdminApi.llmLogs({
|
||||
world_id: appliedFilters.world_id || undefined,
|
||||
stage: appliedFilters.stage || undefined,
|
||||
status_filter: appliedFilters.status_filter || undefined,
|
||||
page,
|
||||
per_page: perPage,
|
||||
});
|
||||
setData(res);
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [appliedFilters, page, perPage, pushToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const applyFilters = () => {
|
||||
setAppliedFilters(filters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const openDetail = async (id: string) => {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const d = await AdminApi.llmLog(id);
|
||||
setDetail(d);
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card title={t("admin.tab_logs")}>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<Input
|
||||
label={t("admin.logs_filter_world")}
|
||||
value={filters.world_id}
|
||||
onChange={(e) => setFilters({ ...filters, world_id: e.target.value })}
|
||||
placeholder="uuid"
|
||||
/>
|
||||
<Input
|
||||
label={t("admin.logs_filter_stage")}
|
||||
value={filters.stage}
|
||||
onChange={(e) => setFilters({ ...filters, stage: e.target.value })}
|
||||
placeholder="world_builder / iteration / ..."
|
||||
/>
|
||||
<Input
|
||||
label={t("admin.logs_filter_status")}
|
||||
value={filters.status_filter}
|
||||
onChange={(e) => setFilters({ ...filters, status_filter: e.target.value })}
|
||||
placeholder="success / error"
|
||||
/>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={applyFilters} variant="secondary" fullWidth>
|
||||
{t("admin.logs_filter_apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 p-4 text-sm text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<p className="p-4 text-sm text-fg-muted">{t("common.no_data")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-fg-dim/20 text-left text-xs uppercase text-fg-muted">
|
||||
<th className="p-2">{t("admin.logs_stage")}</th>
|
||||
<th className="p-2">{t("admin.logs_status")}</th>
|
||||
<th className="p-2">{t("admin.logs_latency")}</th>
|
||||
<th className="p-2">{t("admin.logs_tokens")}</th>
|
||||
<th className="p-2">{t("admin.logs_created")}</th>
|
||||
<th className="p-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((log) => (
|
||||
<tr key={log.id} className="border-b border-fg-dim/10 hover:bg-bg-soft">
|
||||
<td className="p-2 font-mono text-xs">{log.stage}</td>
|
||||
<td className="p-2">
|
||||
<span
|
||||
className={`badge ${
|
||||
log.status === "success"
|
||||
? "bg-ok/15 text-ok"
|
||||
: log.status === "error"
|
||||
? "bg-err/15 text-err"
|
||||
: "bg-warn/15 text-warn"
|
||||
}`}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 text-fg-muted">
|
||||
{log.latency_ms != null ? `${log.latency_ms} ms` : "—"}
|
||||
</td>
|
||||
<td className="p-2 text-fg-muted">
|
||||
{log.tokens != null ? log.tokens : "—"}
|
||||
</td>
|
||||
<td className="p-2 text-fg-muted">{formatDate(log.created_at)}</td>
|
||||
<td className="p-2 text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => openDetail(log.id)}>
|
||||
{t("common.details")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{data && data.total > perPage && (
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-fg-muted">
|
||||
<span>
|
||||
{t("common.previous")} {page * perPage - perPage + 1}–{Math.min(page * perPage, data.total)} / {data.total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
{t("common.previous")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={page * perPage >= data.total}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
title={t("admin.logs_detail")}
|
||||
size="xl"
|
||||
footer={
|
||||
<Button variant="secondary" onClick={() => setDetailOpen(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-4 text-sm text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
) : detail ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label={t("admin.logs_stage")} value={detail.stage} />
|
||||
<Field label={t("admin.logs_status")} value={detail.status} />
|
||||
<Field
|
||||
label={t("admin.logs_latency")}
|
||||
value={detail.latency_ms != null ? `${detail.latency_ms} ms` : "—"}
|
||||
/>
|
||||
<Field
|
||||
label={t("admin.logs_tokens")}
|
||||
value={detail.tokens != null ? String(detail.tokens) : "—"}
|
||||
/>
|
||||
<Field label={t("common.name")} value={detail.model || "—"} />
|
||||
<Field label={t("admin.logs_created")} value={formatDate(detail.created_at)} />
|
||||
</div>
|
||||
{detail.error && (
|
||||
<Section title={t("admin.logs_error")}>
|
||||
<pre className="whitespace-pre-wrap text-err">{detail.error}</pre>
|
||||
</Section>
|
||||
)}
|
||||
{detail.prompt && (
|
||||
<Section title={t("admin.logs_prompt")}>
|
||||
<pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs">
|
||||
{detail.prompt}
|
||||
</pre>
|
||||
</Section>
|
||||
)}
|
||||
{detail.response && (
|
||||
<Section title={t("admin.logs_response")}>
|
||||
<pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs">
|
||||
{detail.response}
|
||||
</pre>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">{t("common.no_data")}</p>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-fg-dim">{label}</p>
|
||||
<p className="text-fg break-all">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="label">{title}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
132
frontend/src/components/admin/SettingsPanel.tsx
Normal file
132
frontend/src/components/admin/SettingsPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { AdminSettingsResponse } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
const GROUP_PREFIXES: Array<{ group: string; prefixes: string[]; labelKey: string }> = [
|
||||
{ group: "llm", prefixes: ["llm_", "llm."], labelKey: "admin.group_llm" },
|
||||
{ group: "embeddings", prefixes: ["embeddings_", "embeddings."], labelKey: "admin.group_embeddings" },
|
||||
{ group: "qdrant", prefixes: ["qdrant_", "qdrant."], labelKey: "admin.group_qdrant" },
|
||||
{ group: "ui", prefixes: ["ui_", "ui.", "site_", "site."], labelKey: "admin.group_ui" },
|
||||
{ group: "game", prefixes: ["game_", "game."], labelKey: "admin.group_game" },
|
||||
];
|
||||
|
||||
function groupFor(key: string): string {
|
||||
const lower = key.toLowerCase();
|
||||
for (const g of GROUP_PREFIXES) {
|
||||
if (g.prefixes.some((p) => lower.startsWith(p))) return g.group;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function SettingsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [data, setData] = useState<AdminSettingsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
AdminApi.settings()
|
||||
.then((res) => {
|
||||
setData(res);
|
||||
setDraft({ ...res.settings });
|
||||
})
|
||||
.catch(() => pushToast("error", t("admin.settings_load_failed")))
|
||||
.finally(() => setLoading(false));
|
||||
}, [pushToast, t]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!data) return {} as Record<string, Array<{ key: string; description?: string }>>;
|
||||
const out: Record<string, Array<{ key: string; description?: string }>> = {};
|
||||
for (const key of Object.keys(data.settings)) {
|
||||
const g = groupFor(key);
|
||||
(out[g] ||= []).push({ key, description: data.descriptions?.[key] });
|
||||
}
|
||||
return out;
|
||||
}, [data]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!data) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Save only changed keys
|
||||
const diff: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(draft)) {
|
||||
if (data.settings[k] !== v) diff[k] = v;
|
||||
}
|
||||
if (Object.keys(diff).length === 0) {
|
||||
pushToast("info", "No changes to save.");
|
||||
return;
|
||||
}
|
||||
const res = await AdminApi.updateSettings(diff);
|
||||
setData(res);
|
||||
setDraft({ ...res.settings });
|
||||
pushToast("success", t("admin.settings_saved"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed";
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Spinner /> <span className="ml-2 text-sm text-fg-muted">{t("common.loading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <p className="text-sm text-fg-muted">{t("common.no_data")}</p>;
|
||||
}
|
||||
|
||||
const groupOrder = ["llm", "embeddings", "qdrant", "ui", "game", "other"];
|
||||
const groupLabelKey: Record<string, string> = {
|
||||
llm: "admin.group_llm",
|
||||
embeddings: "admin.group_embeddings",
|
||||
qdrant: "admin.group_qdrant",
|
||||
ui: "admin.group_ui",
|
||||
game: "admin.group_game",
|
||||
other: "common.details",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{groupOrder.map((g) => {
|
||||
const entries = grouped[g];
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<Card key={g} title={t(groupLabelKey[g] || "common.details")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{entries.map(({ key, description }) => (
|
||||
<Input
|
||||
key={key}
|
||||
label={key}
|
||||
hint={description}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/admin/StatsPanel.tsx
Normal file
54
frontend/src/components/admin/StatsPanel.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { AdminStats } from "@/types";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
export function StatsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
AdminApi.stats()
|
||||
.then(setStats)
|
||||
.catch((err) => pushToast("error", err instanceof Error ? err.message : "Failed"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [pushToast]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 text-sm text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!stats) {
|
||||
return <p className="text-sm text-fg-muted">{t("common.no_data")}</p>;
|
||||
}
|
||||
|
||||
const cards: Array<{ label: string; value: string | number }> = [
|
||||
{ label: t("admin.stats_users"), value: stats.users },
|
||||
{ label: t("admin.stats_worlds"), value: stats.worlds },
|
||||
{ label: t("admin.stats_steps"), value: stats.steps },
|
||||
{
|
||||
label: t("admin.stats_avg_latency"),
|
||||
value: stats.avg_llm_latency_ms != null ? `${Math.round(stats.avg_llm_latency_ms)} ms` : "—",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.label}>
|
||||
<p className="text-xs uppercase tracking-wide text-fg-muted">{c.label}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-fg">{c.value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
301
frontend/src/components/admin/TestButtons.tsx
Normal file
301
frontend/src/components/admin/TestButtons.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type {
|
||||
EmbeddingsProbeResult,
|
||||
EmbeddingsTestResult,
|
||||
LlmTestResult,
|
||||
LlmToolsTestResult,
|
||||
RecreateCollectionsResult,
|
||||
} from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
export function TestButtons() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LlmTestCard />
|
||||
<LlmToolsTestCard />
|
||||
<EmbeddingsTestCard />
|
||||
<ProbeDimensionCard />
|
||||
<RecreateCollectionsCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LlmTestCard() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<LlmTestResult | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await AdminApi.testLlm(apiUrl, apiKey, model);
|
||||
setResult(r);
|
||||
if (!r.ok) pushToast("error", t("admin.failed"));
|
||||
else pushToast("success", t("admin.ok"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed";
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t("admin.test_llm")}>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Input label={t("admin.api_url")} value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} placeholder="https://api.openai.com/v1/chat/completions" />
|
||||
<Input label={t("admin.api_key")} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
||||
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} placeholder="gpt-4o-mini" />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
|
||||
{loading ? t("admin.running") : t("admin.run_test")}
|
||||
</Button>
|
||||
{loading && <Spinner size="sm" />}
|
||||
</div>
|
||||
{result && <TestResultCard result={result} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LlmToolsTestCard() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<LlmToolsTestResult | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await AdminApi.testLlmTools({
|
||||
api_url: apiUrl,
|
||||
api_key: apiKey,
|
||||
model,
|
||||
});
|
||||
setResult(r);
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed"));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t("admin.test_llm_tools")}>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Input label={t("admin.api_url")} value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
|
||||
<Input label={t("admin.api_key")} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
||||
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
|
||||
{loading ? t("admin.running") : t("admin.run_test")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && <TestResultCard result={result} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddingsTestCard() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<EmbeddingsTestResult | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await AdminApi.testEmbeddings({
|
||||
api_url: apiUrl,
|
||||
api_key: apiKey,
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
setResult(r);
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed"));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t("admin.test_embeddings")}>
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Input label={t("admin.api_url")} value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
|
||||
<Input label={t("admin.api_key")} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
||||
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
<Input label={t("admin.provider")} value={provider} onChange={(e) => setProvider(e.target.value)} placeholder="openai" />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
|
||||
{loading ? t("admin.running") : t("admin.run_test")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && <TestResultCard result={result} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProbeDimensionCard() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<EmbeddingsProbeResult | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await AdminApi.probeDimension({
|
||||
api_url: apiUrl,
|
||||
api_key: apiKey,
|
||||
model,
|
||||
provider,
|
||||
});
|
||||
setResult(r);
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed"));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t("admin.probe_dimension")}>
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Input label={t("admin.api_url")} value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
|
||||
<Input label={t("admin.api_key")} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
||||
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
<Input label={t("admin.provider")} value={provider} onChange={(e) => setProvider(e.target.value)} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
|
||||
{loading ? t("admin.running") : t("admin.run_test")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && <TestResultCard result={result} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RecreateCollectionsCard() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<RecreateCollectionsResult | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await AdminApi.recreateCollections();
|
||||
setResult(r);
|
||||
pushToast("success", `${t("admin.ok")}: dropped=${r.dropped} created=${r.created} dim=${r.dimension}`);
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
</p>
|
||||
<Button onClick={run} loading={loading} variant="danger">
|
||||
{loading ? t("admin.running") : t("admin.recreate_collections")}
|
||||
</Button>
|
||||
{result && (
|
||||
<div className="mt-3 text-sm">
|
||||
<p>
|
||||
<span className="text-fg-muted">Dropped:</span> {result.dropped}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-fg-muted">Created:</span> {result.created}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-fg-muted">{t("admin.dimension")}:</span> {result.dimension}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TestResultCard({ result }: { result: Record<string, unknown> }) {
|
||||
const { t } = useTranslation();
|
||||
const ok = result.ok === true;
|
||||
return (
|
||||
<div className={`mt-3 rounded-md border p-3 text-xs ${ok ? "border-ok/30 bg-ok/5" : "border-err/30 bg-err/5"}`}>
|
||||
<p className={`font-semibold ${ok ? "text-ok" : "text-err"}`}>
|
||||
{ok ? t("admin.ok") : t("admin.failed")}
|
||||
</p>
|
||||
{typeof result.elapsed_ms === "number" && (
|
||||
<p className="mt-1 text-fg-muted">
|
||||
{t("admin.elapsed_ms")}: {result.elapsed_ms}
|
||||
</p>
|
||||
)}
|
||||
{typeof result.dimension === "number" && (
|
||||
<p className="mt-1 text-fg-muted">
|
||||
{t("admin.dimension")}: {result.dimension}
|
||||
</p>
|
||||
)}
|
||||
{typeof result.model === "string" && (
|
||||
<p className="mt-1 text-fg-muted">
|
||||
{t("admin.model")}: {result.model}
|
||||
</p>
|
||||
)}
|
||||
{typeof result.response === "string" && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2">
|
||||
{result.response}
|
||||
</pre>
|
||||
)}
|
||||
{typeof result.error === "string" && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-err">
|
||||
{result.error}
|
||||
</pre>
|
||||
)}
|
||||
{Array.isArray(result.first_5_values) && (
|
||||
<p className="mt-1 text-fg-muted font-mono">
|
||||
first_5_values: [{(result.first_5_values as number[]).slice(0, 5).map((v) => typeof v === "number" ? v.toFixed(4) : String(v)).join(", ")}]
|
||||
</p>
|
||||
)}
|
||||
{result.tool_calls != null && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2">
|
||||
{JSON.stringify(result.tool_calls, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/admin/UsersTable.tsx
Normal file
118
frontend/src/components/admin/UsersTable.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { User } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
export function UsersTable() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await AdminApi.users();
|
||||
setUsers(res.items);
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pushToast]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const update = async (id: string, payload: { is_admin?: boolean; is_active?: boolean }) => {
|
||||
setUpdatingId(id);
|
||||
try {
|
||||
const updated = await AdminApi.updateUser(id, payload);
|
||||
setUsers((list) => list.map((u) => (u.id === id ? updated : u)));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t("admin.tab_users")}>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 p-4 text-sm text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<p className="p-4 text-sm text-fg-muted">{t("common.no_data")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-fg-dim/20 text-left text-xs uppercase text-fg-muted">
|
||||
<th className="p-2">{t("admin.users_email")}</th>
|
||||
<th className="p-2">{t("admin.users_username")}</th>
|
||||
<th className="p-2">{t("admin.users_admin")}</th>
|
||||
<th className="p-2">{t("admin.users_active")}</th>
|
||||
<th className="p-2">{t("admin.users_created")}</th>
|
||||
<th className="p-2">{t("admin.users_last_login")}</th>
|
||||
<th className="p-2">{t("common.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-fg-dim/10 hover:bg-bg-soft">
|
||||
<td className="p-2 truncate max-w-[200px]">{u.email}</td>
|
||||
<td className="p-2">{u.username}</td>
|
||||
<td className="p-2">
|
||||
<span className={`badge ${u.is_admin ? "bg-accent/15 text-accent" : "bg-bg-soft text-fg-muted"}`}>
|
||||
{u.is_admin ? t("common.yes") : t("common.no")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={`badge ${u.is_active !== false ? "bg-ok/15 text-ok" : "bg-err/15 text-err"}`}>
|
||||
{u.is_active !== false ? t("common.yes") : t("common.no")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 text-fg-muted">{formatDate(u.created_at)}</td>
|
||||
<td className="p-2 text-fg-muted">{u.last_login_at ? formatDate(u.last_login_at) : "—"}</td>
|
||||
<td className="p-2 space-x-1 whitespace-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={updatingId === u.id}
|
||||
onClick={() => update(u.id, { is_admin: !u.is_admin })}
|
||||
>
|
||||
{u.is_admin ? t("admin.users_remove_admin") : t("admin.users_make_admin")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={updatingId === u.id}
|
||||
onClick={() => update(u.id, { is_active: u.is_active === false })}
|
||||
>
|
||||
{u.is_active !== false ? t("admin.users_deactivate") : t("admin.users_activate")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
41
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
41
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
export interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
requireAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
|
||||
const location = useLocation();
|
||||
const status = useAuthStore((s) => s.status);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const bootstrap = useAuthStore((s) => s.bootstrap);
|
||||
const bootstrapped = useAuthStore((s) => s.status !== "idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "idle") {
|
||||
void bootstrap();
|
||||
}
|
||||
}, [status, bootstrap]);
|
||||
|
||||
if (!bootstrapped || status === "loading") {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] gap-2 text-sm text-fg-muted">
|
||||
<Spinner /> Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== "authenticated" || !user) {
|
||||
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
||||
}
|
||||
|
||||
if (requireAdmin && !user.is_admin) {
|
||||
return <Navigate to="/worlds" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
81
frontend/src/components/sessions/ActionInput.tsx
Normal file
81
frontend/src/components/sessions/ActionInput.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
export interface ActionInputProps {
|
||||
onSubmit: (action: string) => void;
|
||||
submitting: boolean;
|
||||
suggestedActions: string[];
|
||||
onSuggestedClick?: (action: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function ActionInput({
|
||||
onSubmit,
|
||||
submitting,
|
||||
suggestedActions,
|
||||
onSuggestedClick,
|
||||
placeholder,
|
||||
className,
|
||||
autoFocus = false,
|
||||
}: ActionInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = text.trim();
|
||||
if (!value || submitting) return;
|
||||
onSubmit(value);
|
||||
setText("");
|
||||
};
|
||||
|
||||
const handleSuggested = (action: string) => {
|
||||
if (submitting) return;
|
||||
if (onSuggestedClick) onSuggestedClick(action);
|
||||
else onSubmit(action);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{suggestedActions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{suggestedActions.map((a, i) => (
|
||||
<button
|
||||
key={`${a}-${i}`}
|
||||
type="button"
|
||||
onClick={() => handleSuggested(a)}
|
||||
disabled={submitting}
|
||||
className="badge bg-bg-soft text-fg hover:bg-bg-card hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={placeholder ?? t("play.action_placeholder")}
|
||||
disabled={submitting}
|
||||
rows={2}
|
||||
autoFocus={autoFocus}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e as unknown as FormEvent);
|
||||
}
|
||||
}}
|
||||
className="input flex-1 resize-none"
|
||||
/>
|
||||
<Button type="submit" loading={submitting} disabled={!text.trim() && !submitting}>
|
||||
{submitting ? t("play.sending") : t("play.send_action")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
frontend/src/components/sessions/ChatView.tsx
Normal file
143
frontend/src/components/sessions/ChatView.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useSessionStore } from "@/stores/sessionStore";
|
||||
import type { Step } from "@/types";
|
||||
import { ToolCallBubble } from "./ToolCallBubble";
|
||||
|
||||
export interface ChatViewProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatView({ className }: ChatViewProps) {
|
||||
const { t } = useTranslation();
|
||||
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 bottomRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, [recentSteps, streamMessages, streamingText, submitting]);
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{recentSteps.map((step) => (
|
||||
<StepBlock key={step.id} step={step} />
|
||||
))}
|
||||
|
||||
{streamMessages.length > 0 && (
|
||||
<div className="space-y-2 border-l-2 border-accent/40 pl-3">
|
||||
{streamMessages
|
||||
.filter((m) => m.kind === "tool_call" || m.kind === "phase_start" || m.kind === "warning" || m.kind === "error" || m.kind === "trigger_fired" || m.kind === "summary_generated")
|
||||
.map((m) => {
|
||||
if (m.kind === "tool_call" && m.tool) {
|
||||
return (
|
||||
<ToolCallBubble
|
||||
key={m.id}
|
||||
tool={m.tool}
|
||||
result={m.toolResult}
|
||||
success={m.toolSuccess ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (m.kind === "phase_start") {
|
||||
return (
|
||||
<p key={m.id} className="text-xs text-fg-muted">
|
||||
▶ {m.phaseName || m.phase}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (m.kind === "warning" || m.kind === "error") {
|
||||
return (
|
||||
<p
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
m.kind === "error" ? "text-err" : "text-warn",
|
||||
)}
|
||||
>
|
||||
{m.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (m.kind === "trigger_fired") {
|
||||
return (
|
||||
<p key={m.id} className="text-xs text-accent">
|
||||
{t("play.trigger_fired")}: {m.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (m.kind === "summary_generated") {
|
||||
return (
|
||||
<p key={m.id} className="text-xs text-fg-muted">
|
||||
📝 {t("play.summary_generated")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{streamingText && (
|
||||
<div className="card bg-bg-soft">
|
||||
<p className="text-xs font-semibold text-fg-muted mb-1">
|
||||
{t("play.game_master")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-fg scene-cursor">
|
||||
{streamingText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{submitting && !streamingText && (
|
||||
<p className="text-xs text-fg-muted italic">{t("play.streaming")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-err">{error}</p>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepBlock({ step }: { step: Step }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{step.player_action && (
|
||||
<div className="ml-auto max-w-[85%] rounded-lg bg-accent/15 px-3 py-2 text-right">
|
||||
<p className="text-xs font-semibold text-accent mb-0.5">
|
||||
{t("play.you")}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-fg">{step.player_action}</p>
|
||||
</div>
|
||||
)}
|
||||
<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">{step.scene_text}</p>
|
||||
{step.suggested_actions && step.suggested_actions.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{step.suggested_actions.map((a, i) => (
|
||||
<span key={i} className="badge bg-bg-soft text-fg-muted">
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
frontend/src/components/sessions/PhaseProgress.tsx
Normal file
71
frontend/src/components/sessions/PhaseProgress.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export interface PhaseProgressProps {
|
||||
/** Phases that have started (key) with display names. */
|
||||
phases: Array<{ phase: string; name?: string; done?: boolean }>;
|
||||
currentPhase?: string;
|
||||
step?: number;
|
||||
totalSteps?: number;
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PhaseProgress({
|
||||
phases,
|
||||
currentPhase,
|
||||
step,
|
||||
totalSteps,
|
||||
message,
|
||||
className,
|
||||
}: PhaseProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
const progress = useMemo(() => {
|
||||
if (!phases.length) return 0;
|
||||
const done = phases.filter((p) => p.done).length;
|
||||
return Math.round((done / phases.length) * 100);
|
||||
}, [phases]);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2", className)}>
|
||||
<div className="flex items-center justify-between text-xs text-fg-muted">
|
||||
<span>
|
||||
{t("builder.phase")}: {currentPhase || phases[0]?.name || "—"}
|
||||
</span>
|
||||
{typeof step === "number" && typeof totalSteps === "number" && totalSteps > 0 && (
|
||||
<span>
|
||||
{t("builder.step")} {step} {t("builder.of")} {totalSteps}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-bg-soft">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{message && <p className="text-xs text-fg-muted">{message}</p>}
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{phases.map((p) => {
|
||||
const isCurrent = p.phase === currentPhase;
|
||||
return (
|
||||
<li
|
||||
key={p.phase}
|
||||
className={cn(
|
||||
"badge",
|
||||
p.done
|
||||
? "bg-ok/15 text-ok"
|
||||
: isCurrent
|
||||
? "bg-accent/15 text-accent"
|
||||
: "bg-bg-soft text-fg-dim",
|
||||
)}
|
||||
>
|
||||
{p.done ? "✓" : isCurrent ? "▶" : "•"} {p.name || p.phase}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/sessions/SseStatus.tsx
Normal file
34
frontend/src/components/sessions/SseStatus.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export interface SseStatusProps {
|
||||
status: "idle" | "connecting" | "open" | "error" | "closed";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLES = {
|
||||
idle: { dot: "bg-fg-dim", label: "sse.disconnected" },
|
||||
connecting: { dot: "bg-warn animate-pulse", label: "sse.connecting" },
|
||||
open: { dot: "bg-ok animate-pulse", label: "sse.connected" },
|
||||
error: { dot: "bg-err", label: "sse.error" },
|
||||
closed: { dot: "bg-fg-dim", label: "sse.disconnected" },
|
||||
} as const;
|
||||
|
||||
export function SseStatus({ status, className }: SseStatusProps) {
|
||||
const s = STATUS_STYLES[status];
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}
|
||||
role="status"
|
||||
>
|
||||
<span className={cn("h-2 w-2 rounded-full", s.dot)} />
|
||||
{/* Status text is fixed for now; could be i18n'd if needed */}
|
||||
<span>
|
||||
{status === "idle" && "—"}
|
||||
{status === "connecting" && "Connecting…"}
|
||||
{status === "open" && "Connected"}
|
||||
{status === "error" && "Connection error"}
|
||||
{status === "closed" && "Disconnected"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/sessions/ToolCallBubble.tsx
Normal file
45
frontend/src/components/sessions/ToolCallBubble.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export interface ToolCallBubbleProps {
|
||||
tool: string;
|
||||
result: unknown;
|
||||
success: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolCallBubble({ tool, result, success, className }: ToolCallBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const resultPreview = useMemo(() => {
|
||||
try {
|
||||
const str = typeof result === "string" ? result : JSON.stringify(result);
|
||||
if (str.length <= 200) return str;
|
||||
return str.slice(0, 200) + "…";
|
||||
} catch {
|
||||
return String(result);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1.5 text-xs",
|
||||
success
|
||||
? "border-ok/30 bg-ok/5 text-fg"
|
||||
: "border-err/30 bg-err/5 text-fg",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", success ? "bg-ok" : "bg-err")} />
|
||||
<span className="font-mono font-medium text-fg-muted">
|
||||
{t("builder.tool_call")}:
|
||||
</span>
|
||||
<span className="font-mono text-fg">{tool}</span>
|
||||
</div>
|
||||
<p className="mt-1 break-words font-mono text-[10px] text-fg-dim">{resultPreview}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,49 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger" | "outline";
|
||||
size?: "sm" | "md" | "lg";
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
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";
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: "bg-accent text-white hover:bg-accent-hover",
|
||||
secondary: "bg-bg-soft text-fg border border-fg-dim/30 hover:bg-bg-card",
|
||||
danger: "bg-err text-white hover:bg-err/80",
|
||||
ghost: "bg-transparent text-fg-muted hover:bg-bg-soft hover:text-fg",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: "px-2.5 py-1 text-xs",
|
||||
md: "px-4 py-2 text-sm",
|
||||
lg: "px-5 py-2.5 text-base",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = "primary", size = "md", loading = false, fullWidth = false, className, children, disabled, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"btn",
|
||||
VARIANTS[variant],
|
||||
SIZES[size],
|
||||
fullWidth && "w-full",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{loading && <Spinner size="sm" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export function Card({ className, children }: { className?: string; children: ReactNode }) {
|
||||
export interface CardProps {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
}
|
||||
|
||||
export function Card({ title, description, actions, children, className, bodyClassName }: CardProps) {
|
||||
return (
|
||||
<div className={cn("bg-ink-900/70 border border-ink-800 rounded-xl backdrop-blur-sm", className)}>
|
||||
{children}
|
||||
</div>
|
||||
<section className={cn("card", className)}>
|
||||
{(title || actions || description) && (
|
||||
<header className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
{title && <h3 className="text-sm font-semibold text-fg">{title}</h3>}
|
||||
{description && <p className="mt-1 text-xs text-fg-muted">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 gap-2">{actions}</div>}
|
||||
</header>
|
||||
)}
|
||||
<div className={bodyClassName}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -1,70 +1,33 @@
|
||||
import { InputHTMLAttributes, TextareaHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "./cn";
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: ReactNode;
|
||||
error?: string;
|
||||
hint?: ReactNode;
|
||||
containerClassName?: 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";
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ label, error, hint, className, containerClassName, id, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const inputId = id || rest.name;
|
||||
return (
|
||||
<div className={cn("w-full", containerClassName)}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="label">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={cn("input", error && "border-err focus:border-err focus:ring-err", className)}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-err">{error}</p>}
|
||||
{hint && !error && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
75
frontend/src/components/ui/JsonEditor.tsx
Normal file
75
frontend/src/components/ui/JsonEditor.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export interface JsonEditorProps {
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
className?: string;
|
||||
readOnly?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function JsonEditor({ value, onChange, className, readOnly = false, rows = 16 }: JsonEditorProps) {
|
||||
const initialText = useMemo(() => safeStringify(value), [value]);
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Re-sync external value changes if differs from current parsed text
|
||||
useMemo(() => {
|
||||
const external = safeStringify(value);
|
||||
if (external !== text && !error) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (safeStringify(parsed) !== external) {
|
||||
setText(external);
|
||||
}
|
||||
} catch {
|
||||
// user is editing — keep text
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const next = e.target.value;
|
||||
setText(next);
|
||||
if (readOnly) return;
|
||||
if (next.trim() === "") {
|
||||
setError(null);
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(next);
|
||||
setError(null);
|
||||
onChange(parsed);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
readOnly={readOnly}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className={cn(
|
||||
"input font-mono text-xs",
|
||||
error && "border-err focus:border-err focus:ring-err",
|
||||
)}
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-err">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(v ?? null, null, 2);
|
||||
} catch {
|
||||
return String(v ?? "");
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,73 @@
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "./cn";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "./Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ModalProps {
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
closeOnBackdrop?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children, size = "md" }: ModalProps) {
|
||||
const SIZES = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-2xl",
|
||||
xl: "max-w-4xl",
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = "md",
|
||||
closeOnBackdrop = true,
|
||||
}: ModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onEsc);
|
||||
return () => document.removeEventListener("keydown", onEsc);
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [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}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full bg-ink-900 border border-ink-700 rounded-xl shadow-2xl max-h-[90vh] flex flex-col",
|
||||
sizes[size]
|
||||
"relative z-10 w-full rounded-lg border border-fg-dim/30 bg-bg-card shadow-2xl",
|
||||
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>
|
||||
<header className="flex items-center justify-between border-b border-fg-dim/20 p-4">
|
||||
<h2 className="text-base font-semibold text-fg">{title}</h2>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} aria-label={t("common.close")}>
|
||||
✕
|
||||
</Button>
|
||||
</header>
|
||||
<div className="max-h-[70vh] overflow-y-auto p-4">{children}</div>
|
||||
{footer && (
|
||||
<footer className="flex justify-end gap-2 border-t border-fg-dim/20 p-4">{footer}</footer>
|
||||
)}
|
||||
<div className="overflow-y-auto p-4 flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,115 +1,147 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate, Link, useLocation } from "react-router-dom";
|
||||
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 { useUiStore } from "@/store/ui";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { Button } from "./Button";
|
||||
import { cn } from "./cn";
|
||||
|
||||
export function Navbar() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user, logout, isAdmin } = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const logoUrl = useUiStore((s) => s.logoUrl);
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const toggleTheme = useUiStore((s) => s.toggleTheme);
|
||||
const language = useUiStore((s) => s.language);
|
||||
const setLanguage = useUiStore((s) => s.setLanguage);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousedown", onClick);
|
||||
return () => window.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
pushToast("info", t("auth.logged_out"));
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const changeLang = (lang: string) => {
|
||||
i18n.changeLanguage(lang);
|
||||
setLangOpen(false);
|
||||
const handleLangToggle = () => {
|
||||
setLanguage(language === "en" ? "ru" : "en");
|
||||
};
|
||||
|
||||
const isPlay = location.pathname.startsWith("/worlds/") && location.pathname.endsWith("/play");
|
||||
|
||||
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">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="logo"
|
||||
className="w-7 h-7 rounded object-contain"
|
||||
onError={(e) => {
|
||||
// If the configured logo fails to load, hide the broken image
|
||||
// so the navbar degrades gracefully. The bundled /logo.png is
|
||||
// always available as the default fallback.
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<nav className="sticky top-0 z-40 border-b border-fg-dim/20 bg-bg/95 backdrop-blur supports-[backdrop-filter]:bg-bg/80">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to={user ? "/worlds" : "/login"} className="flex items-center gap-2">
|
||||
<span className="text-lg">🎲</span>
|
||||
<span className="font-semibold text-fg">{t("common.app_name")}</span>
|
||||
</Link>
|
||||
{user && (
|
||||
<div className="hidden md:flex items-center gap-1 ml-4">
|
||||
<NavLink to="/worlds" active={location.pathname === "/worlds" || location.pathname === "/"}>
|
||||
{t("nav.worlds")}
|
||||
</NavLink>
|
||||
<NavLink to="/worlds/new" active={location.pathname === "/worlds/new"}>
|
||||
{t("nav.create_world")}
|
||||
</NavLink>
|
||||
{user.is_admin && (
|
||||
<NavLink to="/admin" active={location.pathname.startsWith("/admin")}>
|
||||
{t("nav.admin")}
|
||||
</NavLink>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleLangToggle}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-fg-muted hover:bg-bg-soft hover:text-fg"
|
||||
aria-label={t("lang.switch")}
|
||||
title={t("lang.switch")}
|
||||
>
|
||||
{language === "en" ? "EN" : "RU"}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded-md px-2 py-1 text-xs text-fg-muted hover:bg-bg-soft hover:text-fg"
|
||||
aria-label={t("theme.toggle")}
|
||||
title={t("theme.toggle")}
|
||||
>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
{user && !isPlay && (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-fg hover:bg-bg-soft"
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent text-xs font-bold text-white">
|
||||
{user.username.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="hidden md:inline max-w-[120px] truncate">{user.username}</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 rounded-md border border-fg-dim/30 bg-bg-card shadow-lg">
|
||||
<div className="border-b border-fg-dim/20 px-3 py-2">
|
||||
<p className="text-sm font-medium text-fg truncate">{user.username}</p>
|
||||
<p className="text-xs text-fg-muted truncate">{user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full px-3 py-2 text-left text-sm text-fg hover:bg-bg-soft"
|
||||
>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!user && (
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate("/login")}>
|
||||
{t("auth.login_button")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
to,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
to: string;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
active ? "bg-bg-soft text-fg" : "text-fg-muted hover:bg-bg-soft hover:text-fg",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
27
frontend/src/components/ui/Spinner.tsx
Normal file
27
frontend/src/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export interface SpinnerProps {
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
xs: "h-3 w-3 border-2",
|
||||
sm: "h-4 w-4 border-2",
|
||||
md: "h-6 w-6 border-2",
|
||||
lg: "h-10 w-10 border-4",
|
||||
};
|
||||
|
||||
export function Spinner({ size = "md", className }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
className={cn(
|
||||
"inline-block animate-spin rounded-full border-fg-dim/30 border-t-accent",
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/ui/Textarea.tsx
Normal file
33
frontend/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { forwardRef, type TextareaHTMLAttributes, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: ReactNode;
|
||||
error?: string;
|
||||
hint?: ReactNode;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
||||
{ label, error, hint, className, containerClassName, id, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const taId = id || rest.name;
|
||||
return (
|
||||
<div className={cn("w-full", containerClassName)}>
|
||||
{label && (
|
||||
<label htmlFor={taId} className="label">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={taId}
|
||||
className={cn("input resize-y min-h-[80px]", error && "border-err focus:border-err focus:ring-err", className)}
|
||||
{...rest}
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-err">{error}</p>}
|
||||
{hint && !error && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
98
frontend/src/components/ui/Toast.tsx
Normal file
98
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { ToastKind } from "@/types";
|
||||
|
||||
const KIND_STYLES: Record<ToastKind, string> = {
|
||||
info: "border-accent/40 bg-bg-card",
|
||||
success: "border-ok/40 bg-bg-card",
|
||||
error: "border-err/40 bg-bg-card",
|
||||
warning: "border-warn/40 bg-bg-card",
|
||||
};
|
||||
|
||||
const KIND_DOT: Record<ToastKind, string> = {
|
||||
info: "bg-accent",
|
||||
success: "bg-ok",
|
||||
error: "bg-err",
|
||||
warning: "bg-warn",
|
||||
};
|
||||
|
||||
const KIND_TITLE_KEY: Record<ToastKind, string> = {
|
||||
info: "toast.info_title",
|
||||
success: "toast.success_title",
|
||||
error: "toast.error_title",
|
||||
warning: "toast.warning_title",
|
||||
};
|
||||
|
||||
export interface ToastViewportProps {
|
||||
position?: "top-right" | "bottom-right" | "top-center" | "bottom-center";
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const POSITIONS = {
|
||||
"top-right": "top-4 right-4",
|
||||
"bottom-right": "bottom-4 right-4",
|
||||
"top-center": "top-4 left-1/2 -translate-x-1/2",
|
||||
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
|
||||
};
|
||||
|
||||
export function ToastViewport({ position = "top-right" }: ToastViewportProps) {
|
||||
const items = useToastStore((s) => s.items);
|
||||
const remove = useToastStore((s) => s.remove);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="notifications"
|
||||
className={cn("pointer-events-none fixed z-[100] flex w-full max-w-sm flex-col gap-2", POSITIONS[position])}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<ToastItem
|
||||
key={item.id}
|
||||
kind={item.kind}
|
||||
message={item.message}
|
||||
title={t(KIND_TITLE_KEY[item.kind])}
|
||||
onClose={() => remove(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({
|
||||
kind,
|
||||
message,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-start gap-3 rounded-md border p-3 shadow-lg",
|
||||
"animate-[fadein_0.2s_ease-out]",
|
||||
KIND_STYLES[kind],
|
||||
)}
|
||||
>
|
||||
<span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", KIND_DOT[kind])} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-fg-muted">{title}</p>
|
||||
<p className="mt-0.5 text-sm text-fg break-words whitespace-pre-wrap">{message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-fg-muted hover:text-fg"
|
||||
aria-label="close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { Modal } from "./Modal";
|
||||
export { Button } from "./Button";
|
||||
export { Input, Textarea } from "./Input";
|
||||
export { Card, CardBody, CardHeader } from "./Card";
|
||||
export { cn } from "./cn";
|
||||
@@ -1,104 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
468
frontend/src/components/worlds/WorldBuilder.tsx
Normal file
468
frontend/src/components/worlds/WorldBuilder.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import { useCallback, useEffect, useMemo, 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 { 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";
|
||||
import { Textarea } from "@/components/ui/Textarea";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { PhaseProgress } from "@/components/sessions/PhaseProgress";
|
||||
import { SseStatus } from "@/components/sessions/SseStatus";
|
||||
|
||||
type Mode = "preset" | "form";
|
||||
|
||||
interface BuilderState {
|
||||
phase: "form" | "building" | "done" | "error";
|
||||
currentPhase?: string;
|
||||
phases: Array<{ phase: string; name?: string; done: boolean }>;
|
||||
step?: number;
|
||||
totalSteps?: number;
|
||||
message?: string;
|
||||
introScene: string;
|
||||
logs: string[];
|
||||
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;
|
||||
}
|
||||
|
||||
export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const [mode, setMode] = useState<Mode>("preset");
|
||||
const [presets, setPresets] = useState<PresetListItem[]>([]);
|
||||
const [presetsLoading, setPresetsLoading] = useState(false);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
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 [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [state, setState] = useState<BuilderState>({
|
||||
phase: "form",
|
||||
phases: [],
|
||||
introScene: "",
|
||||
logs: [],
|
||||
sseStatus: "idle",
|
||||
});
|
||||
const [controller, setController] = useState<SseController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPresetsLoading(true);
|
||||
PresetsApi.list()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setPresets(res.items);
|
||||
if (res.items.length > 0) setSelectedPresetId(res.items[0].id);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
pushToast("error", t("worlds.load_failed"));
|
||||
})
|
||||
.finally(() => !cancelled && setPresetsLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pushToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controller?.close();
|
||||
};
|
||||
}, [controller]);
|
||||
|
||||
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;
|
||||
return true;
|
||||
}, [submitting, state.phase, name, playerName, mode, selectedPresetId, formError]);
|
||||
|
||||
const onFormChange = (v: string) => {
|
||||
setFormData(v);
|
||||
try {
|
||||
JSON.parse(v);
|
||||
setFormError(null);
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(event: SseEvent) => {
|
||||
switch (event.event) {
|
||||
case "ping":
|
||||
break;
|
||||
case "error": {
|
||||
const d = event.data as { message?: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
phase: "error",
|
||||
sseStatus: "error",
|
||||
logs: [...s.logs, `[error] ${d?.message || "Stream error"}`],
|
||||
}));
|
||||
pushToast("error", d?.message || t("builder.build_failed"));
|
||||
controller?.close();
|
||||
break;
|
||||
}
|
||||
case "warning": {
|
||||
const d = event.data as { message?: string };
|
||||
setState((s) => ({ ...s, logs: [...s.logs, `[warn] ${d?.message || ""}`] }));
|
||||
break;
|
||||
}
|
||||
case "step": {
|
||||
const d = event.data as { step: number; message: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
step: d.step,
|
||||
message: d.message,
|
||||
logs: [...s.logs, `[${d.step}] ${d.message}`],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "progress": {
|
||||
const d = event.data as { phase?: string; step?: number; total_steps?: number; message?: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
currentPhase: d.phase,
|
||||
step: d.step,
|
||||
totalSteps: d.total_steps,
|
||||
message: d.message,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "phase_start": {
|
||||
const d = event.data as { phase: string; name: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
currentPhase: d.phase,
|
||||
phases: [...s.phases.filter((p) => p.phase !== d.phase), { phase: d.phase, name: d.name, done: false }],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "phase_end": {
|
||||
const d = event.data as { phase: string };
|
||||
setState((s) => ({
|
||||
...s,
|
||||
phases: s.phases.map((p) => (p.phase === d.phase ? { ...p, done: true } : p)),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "world_schema_generated":
|
||||
setState((s) => ({ ...s, logs: [...s.logs, t("builder.schema_generated")] }));
|
||||
break;
|
||||
case "environment_generated":
|
||||
setState((s) => ({ ...s, logs: [...s.logs, t("builder.environment_generated")] }));
|
||||
break;
|
||||
case "entities_generated":
|
||||
setState((s) => ({ ...s, logs: [...s.logs, t("builder.entities_generated")] }));
|
||||
break;
|
||||
case "intro_scene_chunk": {
|
||||
const d = event.data as { text: string };
|
||||
setState((s) => ({ ...s, introScene: s.introScene + d.text }));
|
||||
break;
|
||||
}
|
||||
case "intro_scene_complete": {
|
||||
const d = event.data as { text: string };
|
||||
setState((s) => ({ ...s, introScene: d.text }));
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
phase: "done",
|
||||
sseStatus: "closed",
|
||||
}));
|
||||
controller?.close();
|
||||
pushToast("success", t("builder.build_complete"));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[controller, pushToast, t],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setState({
|
||||
phase: "building",
|
||||
phases: [],
|
||||
introScene: "",
|
||||
logs: [],
|
||||
sseStatus: "connecting",
|
||||
});
|
||||
try {
|
||||
let payload: CreateWorldPayload;
|
||||
if (mode === "preset") {
|
||||
payload = {
|
||||
mode: "preset",
|
||||
preset_id: selectedPresetId,
|
||||
name: name.trim(),
|
||||
language,
|
||||
player_name: playerName.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
} else {
|
||||
const parsedForm = JSON.parse(formData);
|
||||
payload = {
|
||||
mode: "form",
|
||||
form_data: parsedForm,
|
||||
name: name.trim(),
|
||||
language,
|
||||
player_name: playerName.trim(),
|
||||
notes: notes.trim() || undefined,
|
||||
};
|
||||
}
|
||||
const res = await WorldsApi.create(payload);
|
||||
const streamUrl = SessionsApi.builderStreamUrl(res.world_id);
|
||||
const c = subscribeSse(streamUrl, {
|
||||
onOpen: () => setState((s) => ({ ...s, sseStatus: "open" })),
|
||||
onError: () => setState((s) => ({ ...s, sseStatus: "error" })),
|
||||
onClose: () => setState((s) => ({ ...s, sseStatus: "closed" })),
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
setController(c);
|
||||
// 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";
|
||||
pushToast("error", message);
|
||||
setState((s) => ({ ...s, phase: "error" }));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createdWorldIdRef = useMemoRef<string | null>(null);
|
||||
|
||||
// On done, navigate to play page
|
||||
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`);
|
||||
});
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [state.phase, navigate]);
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-4 lg:grid-cols-3", className)}>
|
||||
<Card title={t("builder.step_choose_mode")} className="lg:col-span-1">
|
||||
<div className="space-y-3">
|
||||
<ModeButton
|
||||
active={mode === "preset"}
|
||||
title={t("builder.mode_preset")}
|
||||
description={t("builder.preset_help")}
|
||||
onClick={() => setMode("preset")}
|
||||
/>
|
||||
<ModeButton
|
||||
active={mode === "form"}
|
||||
title={t("builder.mode_form")}
|
||||
description={t("builder.form_help")}
|
||||
onClick={() => setMode("form")}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={mode === "preset" ? t("builder.step_preset") : t("builder.step_form")}
|
||||
className="lg:col-span-2"
|
||||
>
|
||||
{state.phase === "form" && (
|
||||
<div className="space-y-3">
|
||||
{mode === "preset" && (
|
||||
<div>
|
||||
<label className="label">{t("builder.step_preset")}</label>
|
||||
{presetsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
) : presets.length === 0 ? (
|
||||
<p className="text-sm text-fg-muted">{t("common.no_data")}</p>
|
||||
) : (
|
||||
<select
|
||||
value={selectedPresetId}
|
||||
onChange={(e) => setSelectedPresetId(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
{presets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {p.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={t("builder.world_name")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="The Forgotten Realm"
|
||||
/>
|
||||
|
||||
<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("common.language")}</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as Language)}
|
||||
className="input"
|
||||
>
|
||||
<option value="en">{t("builder.language_en")}</option>
|
||||
<option value="ru">{t("builder.language_ru")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
label={t("builder.notes")}
|
||||
hint={t("builder.notes_help")}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSubmit} loading={submitting} disabled={!canSubmit} fullWidth>
|
||||
{submitting ? t("builder.creating") : t("builder.create_button")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(state.phase === "building" || state.phase === "done" || state.phase === "error") && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-fg">{t("builder.build_progress")}</h4>
|
||||
<SseStatus status={state.sseStatus} />
|
||||
</div>
|
||||
{state.phases.length > 0 && (
|
||||
<PhaseProgress
|
||||
phases={state.phases}
|
||||
currentPhase={state.currentPhase}
|
||||
step={state.step}
|
||||
totalSteps={state.totalSteps}
|
||||
message={state.message}
|
||||
/>
|
||||
)}
|
||||
{state.introScene && (
|
||||
<div>
|
||||
<p className="label">{t("builder.intro_scene")}</p>
|
||||
<p className="whitespace-pre-wrap rounded-md border border-fg-dim/20 bg-bg-soft p-3 text-sm text-fg">
|
||||
{state.introScene}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{state.logs.length > 0 && (
|
||||
<details className="rounded-md border border-fg-dim/20 bg-bg-soft p-2">
|
||||
<summary className="cursor-pointer text-xs text-fg-muted">Logs ({state.logs.length})</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto text-[10px] text-fg-dim">
|
||||
{state.logs.join("\n")}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{state.phase === "done" && (
|
||||
<p className="text-sm text-ok">{t("builder.build_complete")}</p>
|
||||
)}
|
||||
{state.phase === "error" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-err">{t("builder.build_failed")}</p>
|
||||
<Button variant="secondary" onClick={() => setState({ ...state, phase: "form" })}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-full rounded-md border p-3 text-left transition-colors",
|
||||
active
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-fg-dim/30 bg-bg-soft hover:border-fg-dim/50",
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-fg">{title}</p>
|
||||
<p className="mt-1 text-xs text-fg-muted">{description}</p>
|
||||
</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;
|
||||
}
|
||||
110
frontend/src/components/worlds/WorldCard.tsx
Normal file
110
frontend/src/components/worlds/WorldCard.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { WorldListItem, WorldStatus } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
export interface WorldCardProps {
|
||||
world: WorldListItem;
|
||||
onDelete?: (world: WorldListItem) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<WorldStatus, string> = {
|
||||
draft: "bg-warn/15 text-warn",
|
||||
building: "bg-accent/15 text-accent",
|
||||
ready: "bg-ok/15 text-ok",
|
||||
failed: "bg-err/15 text-err",
|
||||
archived: "bg-fg-dim/15 text-fg-muted",
|
||||
};
|
||||
|
||||
const STATUS_LABEL_KEY: Record<WorldStatus, string> = {
|
||||
draft: "worlds.status_draft",
|
||||
building: "worlds.status_building",
|
||||
ready: "worlds.status_ready",
|
||||
failed: "worlds.status_failed",
|
||||
archived: "worlds.status_archived",
|
||||
};
|
||||
|
||||
export function WorldCard({ world, onDelete, className }: WorldCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isReady = world.status === "ready";
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"card flex flex-col gap-2 hover:border-accent/40 transition-colors",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-fg truncate">{world.name}</h3>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wide">{world.language}</p>
|
||||
</div>
|
||||
<span className={cn("badge shrink-0", STATUS_BADGE[world.status])}>
|
||||
{t(STATUS_LABEL_KEY[world.status])}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<p className="text-sm text-fg-muted line-clamp-2 min-h-[2.5rem]">
|
||||
{world.description || "—"}
|
||||
</p>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<dt className="text-fg-dim">{t("worlds.player")}</dt>
|
||||
<dd className="text-fg truncate">{world.preview_player_name || "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-fg-dim">{t("worlds.current_time")}</dt>
|
||||
<dd className="text-fg truncate">{world.current_time || "—"}</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="text-fg-dim">{t("worlds.last_played")}</dt>
|
||||
<dd className="text-fg">{world.last_played_at ? formatDate(world.last_played_at) : t("worlds.never_played")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<footer className="mt-2 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isReady ? "primary" : "secondary"}
|
||||
onClick={() => navigate(`/worlds/${world.id}/play`)}
|
||||
disabled={!isReady}
|
||||
fullWidth
|
||||
>
|
||||
{t("worlds.play")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/worlds/${world.id}/edit`)}
|
||||
>
|
||||
{t("worlds.edit")}
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(world)}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
🗑
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
285
frontend/src/components/worlds/WorldEditor.tsx
Normal file
285
frontend/src/components/worlds/WorldEditor.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { WorldsApi, SessionsApi } from "@/lib/api";
|
||||
import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { World } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Textarea } from "@/components/ui/Textarea";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { JsonEditor } from "@/components/ui/JsonEditor";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { SseStatus } from "@/components/sessions/SseStatus";
|
||||
|
||||
type EditorPhase = "idle" | "streaming" | "awaiting_clarification" | "changes_proposed" | "done" | "error";
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
kind: "comment" | "clarification" | "change_proposed" | "info" | "error";
|
||||
text: string;
|
||||
options?: string[];
|
||||
diff?: unknown;
|
||||
}
|
||||
|
||||
export interface WorldEditorProps {
|
||||
world: World;
|
||||
onWorldUpdated?: (world: World) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const [instruction, setInstruction] = useState("");
|
||||
const [phase, setPhase] = useState<EditorPhase>("idle");
|
||||
const [sseStatus, setSseStatus] = useState<"idle" | "connecting" | "open" | "error" | "closed">("idle");
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [draftJson, setDraftJson] = useState<unknown>(world);
|
||||
const [jsonDirty, setJsonDirty] = useState(false);
|
||||
const [submittingJson, setSubmittingJson] = useState(false);
|
||||
|
||||
const controllerRef = useRef<SseController | null>(null);
|
||||
const instructionRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
setDraftJson(world);
|
||||
setJsonDirty(false);
|
||||
}, [world]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshWorld = useCallback(async () => {
|
||||
try {
|
||||
const updated = await WorldsApi.get(world.id);
|
||||
setDraftJson(updated);
|
||||
setJsonDirty(false);
|
||||
onWorldUpdated?.(updated);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [world.id, onWorldUpdated]);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(event: SseEvent) => {
|
||||
switch (event.event) {
|
||||
case "ping":
|
||||
break;
|
||||
case "error": {
|
||||
const d = event.data as { message?: string };
|
||||
setPhase("error");
|
||||
setLogs((l) => [
|
||||
...l,
|
||||
{ id: uid(), kind: "error", text: d?.message || "Stream error" },
|
||||
]);
|
||||
pushToast("error", d?.message || t("editor.streaming"));
|
||||
controllerRef.current?.close();
|
||||
break;
|
||||
}
|
||||
case "warning": {
|
||||
const d = event.data as { message?: string };
|
||||
setLogs((l) => [
|
||||
...l,
|
||||
{ id: uid(), kind: "info", text: d?.message || "" },
|
||||
]);
|
||||
break;
|
||||
}
|
||||
case "comment": {
|
||||
const d = event.data as { text: string };
|
||||
setLogs((l) => [...l, { id: uid(), kind: "comment", text: d.text }]);
|
||||
break;
|
||||
}
|
||||
case "clarification": {
|
||||
const d = event.data as { question: string; options?: string[] };
|
||||
setPhase("awaiting_clarification");
|
||||
setLogs((l) => [
|
||||
...l,
|
||||
{ id: uid(), kind: "clarification", text: d.question, options: d.options },
|
||||
]);
|
||||
break;
|
||||
}
|
||||
case "change_proposed": {
|
||||
const d = event.data as { diff: unknown; comment: string };
|
||||
setPhase("changes_proposed");
|
||||
setLogs((l) => [
|
||||
...l,
|
||||
{ id: uid(), kind: "change_proposed", text: d.comment, diff: d.diff },
|
||||
]);
|
||||
break;
|
||||
}
|
||||
case "apply_changes": {
|
||||
void refreshWorld();
|
||||
pushToast("success", t("editor.changes_applied"));
|
||||
break;
|
||||
}
|
||||
case "discard_changes": {
|
||||
setLogs((l) => [...l, { id: uid(), kind: "info", text: t("editor.changes_discarded") }]);
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
setPhase("done");
|
||||
setSseStatus("closed");
|
||||
controllerRef.current?.close();
|
||||
void refreshWorld();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[pushToast, refreshWorld, t],
|
||||
);
|
||||
|
||||
const sendInstruction = async () => {
|
||||
const text = instruction.trim();
|
||||
if (!text || phase === "streaming") return;
|
||||
setPhase("streaming");
|
||||
setSseStatus("connecting");
|
||||
setLogs([]);
|
||||
instructionRef.current = text;
|
||||
setInstruction("");
|
||||
try {
|
||||
await WorldsApi.edit(world.id, { instruction: text });
|
||||
const url = SessionsApi.editorStreamUrl(world.id, text);
|
||||
const c = subscribeSse(url, {
|
||||
onOpen: () => setSseStatus("open"),
|
||||
onError: () => setSseStatus("error"),
|
||||
onClose: () => setSseStatus("closed"),
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
controllerRef.current?.close();
|
||||
controllerRef.current = c;
|
||||
} catch (err) {
|
||||
setPhase("error");
|
||||
const msg = err instanceof Error ? err.message : "Failed";
|
||||
pushToast("error", msg);
|
||||
}
|
||||
};
|
||||
|
||||
const submitJson = async () => {
|
||||
setSubmittingJson(true);
|
||||
try {
|
||||
const updated = await WorldsApi.update(world.id, draftJson as Partial<World>);
|
||||
setDraftJson(updated);
|
||||
setJsonDirty(false);
|
||||
onWorldUpdated?.(updated);
|
||||
pushToast("success", t("editor.json_saved"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed";
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setSubmittingJson(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-4 lg:grid-cols-2", className)}>
|
||||
<Card title={t("editor.edit_world")}>
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
label={t("editor.instruction")}
|
||||
placeholder={t("editor.instruction_placeholder")}
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
rows={4}
|
||||
disabled={phase === "streaming"}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<SseStatus status={sseStatus} />
|
||||
<Button
|
||||
onClick={sendInstruction}
|
||||
loading={phase === "streaming"}
|
||||
disabled={!instruction.trim() && phase !== "streaming"}
|
||||
>
|
||||
{phase === "streaming" ? t("editor.submit_instruction") : t("editor.send")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<LogEntryView key={log.id} entry={log} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "error" && (
|
||||
<Button variant="secondary" onClick={() => setPhase("idle")}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t("editor.world_json")}
|
||||
actions={
|
||||
<Button size="sm" onClick={submitJson} loading={submittingJson} disabled={!jsonDirty}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<JsonEditor value={draftJson} onChange={(v) => { setDraftJson(v); setJsonDirty(true); }} />
|
||||
{jsonDirty && (
|
||||
<p className="text-xs text-warn">⚠ {t("editor.json_invalid")}</p>
|
||||
)}
|
||||
{submittingJson && (
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogEntryView({ entry }: { entry: LogEntry }) {
|
||||
const { t } = useTranslation();
|
||||
if (entry.kind === "clarification") {
|
||||
return (
|
||||
<div className="rounded-md border border-warn/30 bg-warn/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-warn">
|
||||
{t("editor.clarification")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-fg whitespace-pre-wrap">{entry.text}</p>
|
||||
{entry.options && entry.options.length > 0 && (
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-fg-muted">
|
||||
{entry.options.map((o, i) => (
|
||||
<li key={i}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "change_proposed") {
|
||||
return (
|
||||
<div className="rounded-md border border-accent/30 bg-accent/5 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-accent">
|
||||
{t("editor.change_proposed")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-fg">{entry.text}</p>
|
||||
{entry.diff != null && (
|
||||
<pre className="mt-2 max-h-40 overflow-auto rounded bg-bg-soft p-2 text-[10px] font-mono text-fg-muted">
|
||||
{JSON.stringify(entry.diff, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "error") {
|
||||
return <p className="text-xs text-err">{entry.text}</p>;
|
||||
}
|
||||
return <p className="text-xs text-fg-muted">{entry.text}</p>;
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
265
frontend/src/i18n/en.json
Normal file
265
frontend/src/i18n/en.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"common": {
|
||||
"app_name": "AI-RPG",
|
||||
"loading": "Loading…",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"submit": "Submit",
|
||||
"retry": "Retry",
|
||||
"close": "Close",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"search": "Search",
|
||||
"no_data": "No data",
|
||||
"actions": "Actions",
|
||||
"status": "Status",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"language": "Language",
|
||||
"type": "Type",
|
||||
"preview": "Preview",
|
||||
"details": "Details"
|
||||
},
|
||||
"nav": {
|
||||
"worlds": "Worlds",
|
||||
"create_world": "Create World",
|
||||
"admin": "Admin",
|
||||
"play": "Play",
|
||||
"logout": "Logout",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme",
|
||||
"dark": "Dark",
|
||||
"light": "Light"
|
||||
},
|
||||
"lang": {
|
||||
"switch": "Switch language",
|
||||
"en": "English",
|
||||
"ru": "Русский"
|
||||
},
|
||||
"auth": {
|
||||
"login_title": "Sign in",
|
||||
"register_title": "Create account",
|
||||
"admin_register_title": "Create admin account",
|
||||
"email": "Email",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"password_confirm": "Confirm password",
|
||||
"login_field": "Email or username",
|
||||
"login_button": "Sign in",
|
||||
"register_button": "Register",
|
||||
"admin_token": "Admin invitation token",
|
||||
"have_account": "Already have an account? Sign in",
|
||||
"no_account": "No account? Register",
|
||||
"logged_out": "You have been signed out.",
|
||||
"login_success": "Signed in.",
|
||||
"register_success": "Account created. You can sign in now.",
|
||||
"admin_register_success": "Admin account created. You can sign in now.",
|
||||
"login_failed": "Sign-in failed",
|
||||
"register_failed": "Registration failed",
|
||||
"session_expired": "Session expired, please sign in again."
|
||||
},
|
||||
"worlds": {
|
||||
"title": "Your Worlds",
|
||||
"create_new": "Create new world",
|
||||
"empty": "No worlds yet. Create your first one!",
|
||||
"last_played": "Last played",
|
||||
"never_played": "Not played yet",
|
||||
"current_time": "Current time",
|
||||
"player": "Player",
|
||||
"delete_confirm": "Delete this world? This cannot be undone.",
|
||||
"deleted": "World deleted.",
|
||||
"delete_failed": "Failed to delete world.",
|
||||
"load_failed": "Failed to load worlds.",
|
||||
"play": "Play",
|
||||
"edit": "Edit",
|
||||
"not_found": "World not found.",
|
||||
"status_draft": "Draft",
|
||||
"status_building": "Building",
|
||||
"status_ready": "Ready",
|
||||
"status_failed": "Failed",
|
||||
"status_archived": "Archived"
|
||||
},
|
||||
"builder": {
|
||||
"title": "World Builder",
|
||||
"step_choose_mode": "Choose creation mode",
|
||||
"step_preset": "Pick a preset",
|
||||
"step_form": "Configure details",
|
||||
"step_building": "Building world…",
|
||||
"mode_preset": "From preset",
|
||||
"mode_form": "Custom form",
|
||||
"preset_help": "Start from a pre-configured world template.",
|
||||
"form_help": "Configure the world yourself with custom rules.",
|
||||
"world_name": "World name",
|
||||
"player_name": "Player character name",
|
||||
"notes": "Notes (optional)",
|
||||
"notes_help": "Additional instructions for the Game Master.",
|
||||
"language_en": "English",
|
||||
"language_ru": "Russian",
|
||||
"create_button": "Create world",
|
||||
"creating": "Creating…",
|
||||
"build_progress": "Build progress",
|
||||
"phase": "Phase",
|
||||
"step": "Step",
|
||||
"of": "of",
|
||||
"intro_scene": "Intro scene",
|
||||
"build_complete": "World built successfully!",
|
||||
"build_failed": "World build failed.",
|
||||
"tool_call": "Tool call",
|
||||
"llm_call": "LLM call",
|
||||
"schema_generated": "World schema generated",
|
||||
"environment_generated": "Environment generated",
|
||||
"entities_generated": "Entities generated"
|
||||
},
|
||||
"editor": {
|
||||
"title": "World Editor",
|
||||
"instruction": "Instruction",
|
||||
"instruction_placeholder": "Describe what to change about the world…",
|
||||
"send": "Send",
|
||||
"world_json": "World JSON",
|
||||
"json_saved": "World JSON updated.",
|
||||
"json_invalid": "Invalid JSON.",
|
||||
"clarification": "Clarification needed",
|
||||
"change_proposed": "Change proposed",
|
||||
"comment": "Comment",
|
||||
"apply_changes": "Apply changes",
|
||||
"discard_changes": "Discard changes",
|
||||
"changes_applied": "Changes applied.",
|
||||
"changes_discarded": "Changes discarded.",
|
||||
"edit_world": "Edit world",
|
||||
"submit_instruction": "Submitting…",
|
||||
"streaming": "Streaming…"
|
||||
},
|
||||
"play": {
|
||||
"title": "Play",
|
||||
"environment": "Environment",
|
||||
"location": "Location",
|
||||
"time_of_day": "Time of day",
|
||||
"weather": "Weather",
|
||||
"player": "Player",
|
||||
"hp": "HP",
|
||||
"level": "Level",
|
||||
"inventory": "Inventory",
|
||||
"conditions": "Conditions",
|
||||
"npcs": "NPCs",
|
||||
"items": "Items",
|
||||
"plot_rails": "Plot rails",
|
||||
"chat": "Chat",
|
||||
"scene": "Scene",
|
||||
"you": "You",
|
||||
"game_master": "Game Master",
|
||||
"action_placeholder": "What do you do?",
|
||||
"send_action": "Send",
|
||||
"sending": "Sending…",
|
||||
"suggested_actions": "Suggested actions",
|
||||
"no_suggestions": "No suggestions yet.",
|
||||
"retry_last": "Retry last step",
|
||||
"rollback": "Rollback one step",
|
||||
"rollback_confirm": "Rollback the last step?",
|
||||
"rolled_back": "Rolled back one step.",
|
||||
"streaming": "AI is responding…",
|
||||
"scene_chunk": "Scene",
|
||||
"iteration_complete": "Step complete",
|
||||
"trigger_fired": "Trigger fired",
|
||||
"summary_generated": "Summary generated",
|
||||
"load_failed": "Failed to load session.",
|
||||
"no_actions_yet": "Take an action to begin."
|
||||
},
|
||||
"sse": {
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"error": "Connection error",
|
||||
"reconnecting": "Reconnecting…"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Dashboard",
|
||||
"tab_settings": "Settings",
|
||||
"tab_logs": "LLM Logs",
|
||||
"tab_users": "Users",
|
||||
"tab_stats": "Stats",
|
||||
"tab_test": "Diagnostics",
|
||||
"tab_icons": "Icons",
|
||||
"not_admin": "You do not have admin access.",
|
||||
"settings_saved": "Settings saved.",
|
||||
"settings_save_failed": "Failed to save settings.",
|
||||
"settings_load_failed": "Failed to load settings.",
|
||||
"group_llm": "LLM",
|
||||
"group_embeddings": "Embeddings",
|
||||
"group_qdrant": "Qdrant",
|
||||
"group_ui": "UI",
|
||||
"group_game": "Game",
|
||||
"logs_filter_world": "World ID",
|
||||
"logs_filter_stage": "Stage",
|
||||
"logs_filter_status": "Status",
|
||||
"logs_filter_apply": "Apply filters",
|
||||
"logs_stage": "Stage",
|
||||
"logs_status": "Status",
|
||||
"logs_latency": "Latency",
|
||||
"logs_tokens": "Tokens",
|
||||
"logs_created": "Created",
|
||||
"logs_detail": "Log detail",
|
||||
"logs_prompt": "Prompt",
|
||||
"logs_response": "Response",
|
||||
"logs_error": "Error",
|
||||
"users_email": "Email",
|
||||
"users_username": "Username",
|
||||
"users_admin": "Admin",
|
||||
"users_active": "Active",
|
||||
"users_created": "Created",
|
||||
"users_last_login": "Last login",
|
||||
"users_make_admin": "Make admin",
|
||||
"users_remove_admin": "Remove admin",
|
||||
"users_activate": "Activate",
|
||||
"users_deactivate": "Deactivate",
|
||||
"stats_users": "Users",
|
||||
"stats_worlds": "Worlds",
|
||||
"stats_steps": "Steps",
|
||||
"stats_avg_latency": "Avg LLM latency",
|
||||
"test_llm": "Test LLM",
|
||||
"test_llm_tools": "Test LLM tools",
|
||||
"test_embeddings": "Test embeddings",
|
||||
"probe_dimension": "Probe dimension",
|
||||
"recreate_collections": "Recreate collections",
|
||||
"api_url": "API URL",
|
||||
"api_key": "API key",
|
||||
"model": "Model",
|
||||
"provider": "Provider",
|
||||
"run_test": "Run test",
|
||||
"running": "Running…",
|
||||
"result": "Result",
|
||||
"elapsed_ms": "Elapsed (ms)",
|
||||
"dimension": "Dimension",
|
||||
"ok": "OK",
|
||||
"failed": "Failed",
|
||||
"icons_favicon": "Favicon",
|
||||
"icons_logo": "Logo",
|
||||
"icons_og": "Open Graph image",
|
||||
"icons_upload": "Upload",
|
||||
"icons_uploaded": "Icon uploaded.",
|
||||
"icons_upload_failed": "Failed to upload icon.",
|
||||
"choose_file": "Choose file"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong.",
|
||||
"network": "Network error. Check your connection.",
|
||||
"unauthorized": "Unauthorized.",
|
||||
"not_found": "Not found.",
|
||||
"server_error": "Server error.",
|
||||
"forbidden": "Forbidden.",
|
||||
"validation": "Validation error."
|
||||
},
|
||||
"toast": {
|
||||
"error_title": "Error",
|
||||
"success_title": "Success",
|
||||
"warning_title": "Warning",
|
||||
"info_title": "Info"
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
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",
|
||||
login_or_email: "Email or 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...",
|
||||
editor_chat_title: "AI chat",
|
||||
editor_chat_desc: "Describe changes — the AI will update the world definition.",
|
||||
editor_chat_empty: "Nothing yet. Tell the AI what to change.",
|
||||
editor_chat_ph: "e.g. add a vampire faction in the south...",
|
||||
editor_you: "You",
|
||||
editor_pending_defn: "AI proposed a new definition.",
|
||||
editor_apply: "Apply",
|
||||
editor_reset: "Reset chat",
|
||||
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",
|
||||
retry: "Retry",
|
||||
},
|
||||
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",
|
||||
trigger_settings_desc: "Fire when in-world time advances (no polling)",
|
||||
triggers_enabled: "Enabled",
|
||||
triggers_enabled_desc: "Triggers fire automatically inside the orchestrator when world time advances past their scheduled fire_at.",
|
||||
ui_settings: "UI customization",
|
||||
ui_settings_desc: "Branding shown to all users (logo, favicon).",
|
||||
ui_logo_url: "Logo URL",
|
||||
ui_logo_url_hint:
|
||||
"Path (e.g. /logo.png), full URL (https://.../logo.png), or data: URI. Default /logo.png is the bundled Mikan logo. Used in navbar, home page, and browser tab.",
|
||||
ui_logo_preview: "Preview",
|
||||
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}}",
|
||||
llm_test: "Test LLM",
|
||||
llm_test_testing: "Testing...",
|
||||
llm_test_tools: "Test LLM (with tools)",
|
||||
llm_test_tools_testing: "Testing tool calls...",
|
||||
llm_test_ok: "OK ({{latency}}ms): {{preview}}",
|
||||
llm_test_fail: "Error: {{error}}",
|
||||
llm_test_tools_ok: "Tool calls: {{ok}} | name={{name}} | args={{args}}",
|
||||
llm_test_tools_ok_with_call: "OK — model called {{name}}({{args}}) in {{latency}}ms",
|
||||
llm_test_tools_ok_no_call: "WARNING — model responded but did NOT call the tool. Text: {{text}}",
|
||||
save: "Save",
|
||||
saved: "Saved!",
|
||||
llm_logs: "LLM logs",
|
||||
users: "Users",
|
||||
users_actions: "Actions",
|
||||
users_ban: "Ban",
|
||||
users_unban: "Unban",
|
||||
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.",
|
||||
},
|
||||
};
|
||||
@@ -1,25 +1,26 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { ru } from "./ru";
|
||||
import { en } from "./en";
|
||||
import en from "./en.json";
|
||||
import ru from "./ru.json";
|
||||
|
||||
i18n
|
||||
export const SUPPORTED_LANGUAGES = ["en", "ru"] as const;
|
||||
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
void i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
ru: { translation: ru },
|
||||
en: { translation: en },
|
||||
ru: { translation: ru },
|
||||
},
|
||||
// English is the default. The user can switch via the language picker
|
||||
// in the navbar; the choice is cached in localStorage and overrides
|
||||
// browser settings on subsequent visits.
|
||||
fallbackLng: "en",
|
||||
supportedLngs: ["en", "ru"],
|
||||
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||
interpolation: { escapeValue: false },
|
||||
detection: {
|
||||
order: ["localStorage", "navigator"],
|
||||
lookupLocalStorage: "airpg_lang",
|
||||
caches: ["localStorage"],
|
||||
},
|
||||
});
|
||||
|
||||
265
frontend/src/i18n/ru.json
Normal file
265
frontend/src/i18n/ru.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"common": {
|
||||
"app_name": "AI-RPG",
|
||||
"loading": "Загрузка…",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"create": "Создать",
|
||||
"back": "Назад",
|
||||
"next": "Далее",
|
||||
"previous": "Назад",
|
||||
"submit": "Отправить",
|
||||
"retry": "Повторить",
|
||||
"close": "Закрыть",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"search": "Поиск",
|
||||
"no_data": "Нет данных",
|
||||
"actions": "Действия",
|
||||
"status": "Статус",
|
||||
"name": "Название",
|
||||
"description": "Описание",
|
||||
"language": "Язык",
|
||||
"type": "Тип",
|
||||
"preview": "Просмотр",
|
||||
"details": "Подробности"
|
||||
},
|
||||
"nav": {
|
||||
"worlds": "Миры",
|
||||
"create_world": "Создать мир",
|
||||
"admin": "Админка",
|
||||
"play": "Играть",
|
||||
"logout": "Выйти",
|
||||
"profile": "Профиль"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Переключить тему",
|
||||
"dark": "Тёмная",
|
||||
"light": "Светлая"
|
||||
},
|
||||
"lang": {
|
||||
"switch": "Сменить язык",
|
||||
"en": "English",
|
||||
"ru": "Русский"
|
||||
},
|
||||
"auth": {
|
||||
"login_title": "Вход",
|
||||
"register_title": "Создать аккаунт",
|
||||
"admin_register_title": "Создать аккаунт администратора",
|
||||
"email": "Электронная почта",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"password_confirm": "Подтвердите пароль",
|
||||
"login_field": "Почта или имя пользователя",
|
||||
"login_button": "Войти",
|
||||
"register_button": "Зарегистрироваться",
|
||||
"admin_token": "Токен приглашения администратора",
|
||||
"have_account": "Уже есть аккаунт? Войдите",
|
||||
"no_account": "Нет аккаунта? Зарегистрируйтесь",
|
||||
"logged_out": "Вы вышли из аккаунта.",
|
||||
"login_success": "Вход выполнен.",
|
||||
"register_success": "Аккаунт создан. Теперь можно войти.",
|
||||
"admin_register_success": "Аккаунт администратора создан. Теперь можно войти.",
|
||||
"login_failed": "Не удалось войти",
|
||||
"register_failed": "Не удалось зарегистрироваться",
|
||||
"session_expired": "Сессия истекла, пожалуйста, войдите снова."
|
||||
},
|
||||
"worlds": {
|
||||
"title": "Ваши миры",
|
||||
"create_new": "Создать новый мир",
|
||||
"empty": "Миров пока нет. Создайте первый!",
|
||||
"last_played": "Последняя игра",
|
||||
"never_played": "Ещё не играли",
|
||||
"current_time": "Текущее время",
|
||||
"player": "Игрок",
|
||||
"delete_confirm": "Удалить этот мир? Действие необратимо.",
|
||||
"deleted": "Мир удалён.",
|
||||
"delete_failed": "Не удалось удалить мир.",
|
||||
"load_failed": "Не удалось загрузить миры.",
|
||||
"play": "Играть",
|
||||
"edit": "Изменить",
|
||||
"not_found": "Мир не найден.",
|
||||
"status_draft": "Черновик",
|
||||
"status_building": "Создаётся",
|
||||
"status_ready": "Готов",
|
||||
"status_failed": "Ошибка",
|
||||
"status_archived": "В архиве"
|
||||
},
|
||||
"builder": {
|
||||
"title": "Создание мира",
|
||||
"step_choose_mode": "Выберите режим",
|
||||
"step_preset": "Выберите пресет",
|
||||
"step_form": "Настройте детали",
|
||||
"step_building": "Создание мира…",
|
||||
"mode_preset": "Из пресета",
|
||||
"mode_form": "Своя форма",
|
||||
"preset_help": "Начать с готового шаблона мира.",
|
||||
"form_help": "Настроить мир самостоятельно с собственными правилами.",
|
||||
"world_name": "Название мира",
|
||||
"player_name": "Имя персонажа игрока",
|
||||
"notes": "Заметки (необязательно)",
|
||||
"notes_help": "Дополнительные инструкции для мастера игры.",
|
||||
"language_en": "Английский",
|
||||
"language_ru": "Русский",
|
||||
"create_button": "Создать мир",
|
||||
"creating": "Создание…",
|
||||
"build_progress": "Прогресс создания",
|
||||
"phase": "Фаза",
|
||||
"step": "Шаг",
|
||||
"of": "из",
|
||||
"intro_scene": "Вступительная сцена",
|
||||
"build_complete": "Мир успешно создан!",
|
||||
"build_failed": "Не удалось создать мир.",
|
||||
"tool_call": "Вызов инструмента",
|
||||
"llm_call": "Вызов LLM",
|
||||
"schema_generated": "Схема мира сгенерирована",
|
||||
"environment_generated": "Окружение сгенерировано",
|
||||
"entities_generated": "Сущности сгенерированы"
|
||||
},
|
||||
"editor": {
|
||||
"title": "Редактор мира",
|
||||
"instruction": "Инструкция",
|
||||
"instruction_placeholder": "Опишите, что нужно изменить в мире…",
|
||||
"send": "Отправить",
|
||||
"world_json": "JSON мира",
|
||||
"json_saved": "JSON мира обновлён.",
|
||||
"json_invalid": "Некорректный JSON.",
|
||||
"clarification": "Требуется уточнение",
|
||||
"change_proposed": "Предложено изменение",
|
||||
"comment": "Комментарий",
|
||||
"apply_changes": "Применить изменения",
|
||||
"discard_changes": "Отменить изменения",
|
||||
"changes_applied": "Изменения применены.",
|
||||
"changes_discarded": "Изменения отменены.",
|
||||
"edit_world": "Редактировать мир",
|
||||
"submit_instruction": "Отправка…",
|
||||
"streaming": "Поток…"
|
||||
},
|
||||
"play": {
|
||||
"title": "Игра",
|
||||
"environment": "Окружение",
|
||||
"location": "Локация",
|
||||
"time_of_day": "Время суток",
|
||||
"weather": "Погода",
|
||||
"player": "Игрок",
|
||||
"hp": "ОЗ",
|
||||
"level": "Уровень",
|
||||
"inventory": "Инвентарь",
|
||||
"conditions": "Состояния",
|
||||
"npcs": "NPC",
|
||||
"items": "Предметы",
|
||||
"plot_rails": "Сюжетные линии",
|
||||
"chat": "Чат",
|
||||
"scene": "Сцена",
|
||||
"you": "Вы",
|
||||
"game_master": "Мастер игры",
|
||||
"action_placeholder": "Что вы делаете?",
|
||||
"send_action": "Отправить",
|
||||
"sending": "Отправка…",
|
||||
"suggested_actions": "Предложенные действия",
|
||||
"no_suggestions": "Предложений пока нет.",
|
||||
"retry_last": "Повторить последний шаг",
|
||||
"rollback": "Откатить один шаг",
|
||||
"rollback_confirm": "Откатить последний шаг?",
|
||||
"rolled_back": "Шаг откатан.",
|
||||
"streaming": "AI отвечает…",
|
||||
"scene_chunk": "Сцена",
|
||||
"iteration_complete": "Шаг завершён",
|
||||
"trigger_fired": "Сработал триггер",
|
||||
"summary_generated": "Сгенерирована сводка",
|
||||
"load_failed": "Не удалось загрузить сессию.",
|
||||
"no_actions_yet": "Сделайте действие, чтобы начать."
|
||||
},
|
||||
"sse": {
|
||||
"connecting": "Подключение…",
|
||||
"connected": "Подключено",
|
||||
"disconnected": "Отключено",
|
||||
"error": "Ошибка подключения",
|
||||
"reconnecting": "Переподключение…"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Админ-панель",
|
||||
"tab_settings": "Настройки",
|
||||
"tab_logs": "Логи LLM",
|
||||
"tab_users": "Пользователи",
|
||||
"tab_stats": "Статистика",
|
||||
"tab_test": "Диагностика",
|
||||
"tab_icons": "Иконки",
|
||||
"not_admin": "У вас нет прав администратора.",
|
||||
"settings_saved": "Настройки сохранены.",
|
||||
"settings_save_failed": "Не удалось сохранить настройки.",
|
||||
"settings_load_failed": "Не удалось загрузить настройки.",
|
||||
"group_llm": "LLM",
|
||||
"group_embeddings": "Эмбеддинги",
|
||||
"group_qdrant": "Qdrant",
|
||||
"group_ui": "Интерфейс",
|
||||
"group_game": "Игра",
|
||||
"logs_filter_world": "ID мира",
|
||||
"logs_filter_stage": "Стадия",
|
||||
"logs_filter_status": "Статус",
|
||||
"logs_filter_apply": "Применить фильтры",
|
||||
"logs_stage": "Стадия",
|
||||
"logs_status": "Статус",
|
||||
"logs_latency": "Задержка",
|
||||
"logs_tokens": "Токены",
|
||||
"logs_created": "Создано",
|
||||
"logs_detail": "Детали лога",
|
||||
"logs_prompt": "Запрос",
|
||||
"logs_response": "Ответ",
|
||||
"logs_error": "Ошибка",
|
||||
"users_email": "Почта",
|
||||
"users_username": "Имя пользователя",
|
||||
"users_admin": "Админ",
|
||||
"users_active": "Активен",
|
||||
"users_created": "Создан",
|
||||
"users_last_login": "Последний вход",
|
||||
"users_make_admin": "Сделать админом",
|
||||
"users_remove_admin": "Снять админа",
|
||||
"users_activate": "Активировать",
|
||||
"users_deactivate": "Деактивировать",
|
||||
"stats_users": "Пользователи",
|
||||
"stats_worlds": "Миры",
|
||||
"stats_steps": "Шаги",
|
||||
"stats_avg_latency": "Средняя задержка LLM",
|
||||
"test_llm": "Тест LLM",
|
||||
"test_llm_tools": "Тест инструментов LLM",
|
||||
"test_embeddings": "Тест эмбеддингов",
|
||||
"probe_dimension": "Определить размерность",
|
||||
"recreate_collections": "Пересоздать коллекции",
|
||||
"api_url": "URL API",
|
||||
"api_key": "Ключ API",
|
||||
"model": "Модель",
|
||||
"provider": "Провайдер",
|
||||
"run_test": "Запустить тест",
|
||||
"running": "Выполнение…",
|
||||
"result": "Результат",
|
||||
"elapsed_ms": "Время (мс)",
|
||||
"dimension": "Размерность",
|
||||
"ok": "OK",
|
||||
"failed": "Ошибка",
|
||||
"icons_favicon": "Фавикон",
|
||||
"icons_logo": "Логотип",
|
||||
"icons_og": "Open Graph изображение",
|
||||
"icons_upload": "Загрузить",
|
||||
"icons_uploaded": "Иконка загружена.",
|
||||
"icons_upload_failed": "Не удалось загрузить иконку.",
|
||||
"choose_file": "Выбрать файл"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Что-то пошло не так.",
|
||||
"network": "Сетевая ошибка. Проверьте подключение.",
|
||||
"unauthorized": "Не авторизован.",
|
||||
"not_found": "Не найдено.",
|
||||
"server_error": "Ошибка сервера.",
|
||||
"forbidden": "Доступ запрещён.",
|
||||
"validation": "Ошибка валидации."
|
||||
},
|
||||
"toast": {
|
||||
"error_title": "Ошибка",
|
||||
"success_title": "Успех",
|
||||
"warning_title": "Предупреждение",
|
||||
"info_title": "Информация"
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
export const ru = {
|
||||
app: {
|
||||
title: "AI RPG",
|
||||
subtitle: "Гибкая ролевая игра с ИИ",
|
||||
},
|
||||
nav: {
|
||||
home: "Главная",
|
||||
dashboard: "Мои миры",
|
||||
admin: "Админка",
|
||||
logout: "Выйти",
|
||||
login: "Войти",
|
||||
register: "Регистрация",
|
||||
language: "Язык",
|
||||
},
|
||||
auth: {
|
||||
login_title: "Вход",
|
||||
register_title: "Регистрация",
|
||||
email: "Email",
|
||||
username: "Имя пользователя",
|
||||
login_or_email: "Email или имя пользователя",
|
||||
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: "Создаём мир...",
|
||||
editor_chat_title: "Чат с ИИ",
|
||||
editor_chat_desc: "Опишите изменения — ИИ обновит определение мира.",
|
||||
editor_chat_empty: "Пока пусто. Напишите, что изменить в мире.",
|
||||
editor_chat_ph: "Например: добавь фракцию вампиров на юге...",
|
||||
editor_you: "Вы",
|
||||
editor_pending_defn: "ИИ предложил новое определение.",
|
||||
editor_apply: "Применить",
|
||||
editor_reset: "Сбросить диалог",
|
||||
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: "Ошибка при выполнении итерации",
|
||||
retry: "Повторить",
|
||||
},
|
||||
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: "Отложенные триггеры",
|
||||
trigger_settings_desc: "Срабатывают при сдвиге внутриигрового времени (без поллинга)",
|
||||
triggers_enabled: "Включены",
|
||||
triggers_enabled_desc: "Триггеры срабатывают автоматически внутри оркестратора, когда время мира проходит запланированное fire_at.",
|
||||
ui_settings: "Настройки интерфейса",
|
||||
ui_settings_desc: "Брендинг, видимый всем пользователям (логотип, favicon).",
|
||||
ui_logo_url: "URL логотипа",
|
||||
ui_logo_url_hint:
|
||||
"Путь (напр. /logo.png), полный URL (https://.../logo.png) или data: URI. По умолчанию /logo.png — встроенный логотип Mikan. Используется в навбаре, на главной и во вкладке браузера.",
|
||||
ui_logo_preview: "Превью",
|
||||
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}}",
|
||||
llm_test: "Проверить LLM",
|
||||
llm_test_testing: "Проверяю...",
|
||||
llm_test_tools: "Проверить LLM (с инструментами)",
|
||||
llm_test_tools_testing: "Проверяю вызовы инструментов...",
|
||||
llm_test_ok: "OK ({{latency}}мс): {{preview}}",
|
||||
llm_test_fail: "Ошибка: {{error}}",
|
||||
llm_test_tools_ok: "Вызовы инструментов: {{ok}} | имя={{name}} | аргументы={{args}}",
|
||||
llm_test_tools_ok_with_call: "OK — модель вызвала {{name}}({{args}}) за {{latency}}мс",
|
||||
llm_test_tools_ok_no_call: "ВНИМАНИЕ — модель ответила, но НЕ вызвала инструмент. Текст: {{text}}",
|
||||
save: "Сохранить",
|
||||
saved: "Сохранено!",
|
||||
llm_logs: "Логи LLM",
|
||||
users: "Пользователи",
|
||||
users_actions: "Действия",
|
||||
users_ban: "Забанить",
|
||||
users_unban: "Разбанить",
|
||||
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: "Неизвестная ошибка.",
|
||||
},
|
||||
};
|
||||
@@ -2,58 +2,85 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html {
|
||||
@apply bg-bg text-fg antialiased;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
html:not(.dark) {
|
||||
color-scheme: light;
|
||||
}
|
||||
body {
|
||||
@apply min-h-screen bg-bg text-fg;
|
||||
}
|
||||
* {
|
||||
@apply border-fg-dim/20;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-accent/40 text-fg;
|
||||
}
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-bg-soft;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-fg-dim/40 rounded;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-fg-dim/60;
|
||||
}
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-accent/60;
|
||||
}
|
||||
.input {
|
||||
@apply w-full rounded-md border border-fg-dim/30 bg-bg-soft px-3 py-2 text-sm text-fg placeholder:text-fg-dim focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-fg-muted;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-lg border border-fg-dim/20 bg-bg-card p-4 shadow-sm;
|
||||
}
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-ink-950 text-ink-100 antialiased;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
/* Typewriter cursor for streaming scene text */
|
||||
.scene-cursor::after {
|
||||
content: "▋";
|
||||
margin-left: 1px;
|
||||
animation: blink 1s steps(2) infinite;
|
||||
color: var(--tw-prose-invert-colors, #8b5cf6);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
50.01%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
@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; }
|
||||
/* Light mode overrides */
|
||||
html:not(.dark) body {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
}
|
||||
.pulse-soft {
|
||||
animation: pulse-soft 1.5s ease-in-out infinite;
|
||||
html:not(.dark) .card {
|
||||
@apply bg-white border-gray-200;
|
||||
}
|
||||
html:not(.dark) .input {
|
||||
@apply bg-white border-gray-300 text-gray-900;
|
||||
}
|
||||
|
||||
301
frontend/src/lib/api.ts
Normal file
301
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import type {
|
||||
AdminRegisterPayload,
|
||||
AdminSettingsResponse,
|
||||
AdminStats,
|
||||
AuthResponse,
|
||||
CreateWorldPayload,
|
||||
CreateWorldResponse,
|
||||
EditWorldPayload,
|
||||
EditWorldResponse,
|
||||
EmbeddingsProbeResult,
|
||||
EmbeddingsTestResult,
|
||||
HealthResponse,
|
||||
IterateResponse,
|
||||
LlmLog,
|
||||
LlmLogDetail,
|
||||
LlmTestResult,
|
||||
LlmToolsTestResult,
|
||||
LoginPayload,
|
||||
Paginated,
|
||||
PresetListItem,
|
||||
RecreateCollectionsResult,
|
||||
RegisterPayload,
|
||||
RetryResponse,
|
||||
SessionState,
|
||||
UploadIconResult,
|
||||
User,
|
||||
World,
|
||||
WorldListItem,
|
||||
WorldPreset,
|
||||
} from "@/types";
|
||||
|
||||
const BASE_URL = "/api";
|
||||
const ACCESS_TOKEN_KEY = "airpg_access_token";
|
||||
const REFRESH_TOKEN_KEY = "airpg_refresh_token";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
details?: unknown;
|
||||
constructor(status: number, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh?: string): void {
|
||||
try {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, access);
|
||||
if (refresh) localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
try {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
type QueryValue = string | number | boolean | undefined | null;
|
||||
type Query = Record<string, QueryValue>;
|
||||
|
||||
function buildUrl(path: string, query?: Query): string {
|
||||
const url = `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
if (!query) return url;
|
||||
const params = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null && v !== "") params.append(k, String(v));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${url}?${qs}` : url;
|
||||
}
|
||||
|
||||
function toQuery(obj: Record<string, QueryValue>): Query {
|
||||
return obj;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||
body?: unknown;
|
||||
query?: Query;
|
||||
formData?: FormData;
|
||||
raw?: boolean;
|
||||
}
|
||||
|
||||
async function parseResponse<T>(res: Response): Promise<T> {
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return text as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
let unauthorizedHandler: (() => void) | null = null;
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void): void {
|
||||
unauthorizedHandler = handler;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const refresh = getRefreshToken();
|
||||
if (!refresh) return null;
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refresh }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await parseResponse<AuthResponse>(res);
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
return data.access_token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const { method = "GET", body, query, formData, raw } = opts;
|
||||
const url = buildUrl(path, query);
|
||||
|
||||
const doFetch = (token: string | null): Promise<Response> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (formData) {
|
||||
// Let the browser set the multipart Content-Type with boundary
|
||||
return fetch(url, { method, headers, body: formData });
|
||||
}
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
let res = await doFetch(getAccessToken());
|
||||
|
||||
// Try token refresh on 401
|
||||
if (res.status === 401 && getRefreshToken()) {
|
||||
const newToken = await refreshAccessToken();
|
||||
if (newToken) {
|
||||
res = await doFetch(newToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
unauthorizedHandler?.();
|
||||
const data = (await parseResponse<{ detail?: string }>(res).catch(() => ({}))) as { detail?: string };
|
||||
throw new ApiError(401, data?.detail || "Unauthorized");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await parseResponse<{ detail?: string; message?: string }>(res).catch(() => ({}))) as { detail?: string; message?: string };
|
||||
const message = data?.detail || data?.message || `Request failed (${res.status})`;
|
||||
throw new ApiError(res.status, message, data);
|
||||
}
|
||||
|
||||
if (raw) return res as unknown as T;
|
||||
return parseResponse<T>(res);
|
||||
}
|
||||
|
||||
// ===== Auth API =====
|
||||
export const AuthApi = {
|
||||
register: (payload: RegisterPayload) =>
|
||||
request<User>("/register", { method: "POST", body: payload }),
|
||||
registerAdmin: (payload: AdminRegisterPayload) =>
|
||||
request<User>("/register/admin", { method: "POST", body: payload }),
|
||||
login: (payload: LoginPayload) =>
|
||||
request<AuthResponse>("/auth/login", { method: "POST", body: payload }),
|
||||
refresh: (refreshToken: string) =>
|
||||
request<AuthResponse>("/auth/refresh", { method: "POST", body: { refresh_token: refreshToken } }),
|
||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||
me: () => request<User>("/auth/me"),
|
||||
};
|
||||
|
||||
// ===== Worlds API =====
|
||||
export type ListWorldsQuery = {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
status_filter?: string;
|
||||
};
|
||||
|
||||
export const WorldsApi = {
|
||||
list: (query: ListWorldsQuery = {}) =>
|
||||
request<Paginated<WorldListItem>>("/worlds", { query: { ...query } }),
|
||||
get: (id: string) => request<World>(`/worlds/${id}`),
|
||||
create: (payload: CreateWorldPayload) =>
|
||||
request<CreateWorldResponse>("/worlds", { method: "POST", body: payload }),
|
||||
update: (id: string, payload: Partial<World>) =>
|
||||
request<World>(`/worlds/${id}`, { method: "PATCH", body: payload }),
|
||||
remove: (id: string) => request<void>(`/worlds/${id}`, { method: "DELETE" }),
|
||||
edit: (id: string, payload: EditWorldPayload) =>
|
||||
request<EditWorldResponse>(`/worlds/${id}/edit`, { method: "POST", body: payload }),
|
||||
};
|
||||
|
||||
// ===== Sessions API =====
|
||||
export const SessionsApi = {
|
||||
state: (worldId: string) =>
|
||||
request<SessionState>(`/sessions/worlds/${worldId}/state`),
|
||||
iterate: (worldId: string, action: string, actionSource: string) =>
|
||||
request<IterateResponse>(`/sessions/worlds/${worldId}/iterate`, {
|
||||
method: "POST",
|
||||
body: { action, action_source: actionSource },
|
||||
}),
|
||||
retry: (worldId: string) =>
|
||||
request<RetryResponse>(`/sessions/worlds/${worldId}/retry`, { method: "POST" }),
|
||||
rollback: (worldId: string) =>
|
||||
request<void>(`/sessions/worlds/${worldId}/rollback`, { method: "POST" }),
|
||||
// SSE stream URLs (used by SSE client)
|
||||
iterateStreamUrl: (worldId: string, stepId: string) =>
|
||||
buildUrl(`/sessions/worlds/${worldId}/iterate/stream`, { step_id: stepId }),
|
||||
builderStreamUrl: (worldId: string) =>
|
||||
buildUrl(`/sessions/worlds/${worldId}/builder/stream`),
|
||||
editorStreamUrl: (worldId: string, instruction: string) =>
|
||||
buildUrl(`/sessions/worlds/${worldId}/editor/stream`, { instruction }),
|
||||
};
|
||||
|
||||
// ===== Presets API =====
|
||||
export const PresetsApi = {
|
||||
list: () => request<Paginated<PresetListItem>>("/presets"),
|
||||
get: (id: string) => request<WorldPreset>(`/presets/${id}`),
|
||||
create: (payload: Omit<WorldPreset, "id" | "created_at" | "status" | "version">) =>
|
||||
request<WorldPreset>("/presets", { method: "POST", body: payload }),
|
||||
update: (id: string, payload: Partial<WorldPreset>) =>
|
||||
request<WorldPreset>(`/presets/${id}`, { method: "PATCH", body: payload }),
|
||||
remove: (id: string) => request<void>(`/presets/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
// ===== Admin API =====
|
||||
export type LlmLogsQuery = {
|
||||
world_id?: string;
|
||||
stage?: string;
|
||||
status_filter?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
};
|
||||
|
||||
export const AdminApi = {
|
||||
settings: () => request<AdminSettingsResponse>("/admin/settings"),
|
||||
updateSettings: (settings: Record<string, string>) =>
|
||||
request<AdminSettingsResponse>("/admin/settings", { method: "PATCH", body: { settings } }),
|
||||
llmLogs: (query: LlmLogsQuery = {}) =>
|
||||
request<Paginated<LlmLog>>("/admin/llm-logs", { query: { ...query } }),
|
||||
llmLog: (id: string) => request<LlmLogDetail>(`/admin/llm-logs/${id}`),
|
||||
users: () => request<Paginated<User>>("/admin/users"),
|
||||
updateUser: (id: string, payload: { is_admin?: boolean; is_active?: boolean }) =>
|
||||
request<User>(`/admin/users/${id}`, { method: "PATCH", body: payload }),
|
||||
stats: () => request<AdminStats>("/admin/stats"),
|
||||
testLlm: (apiUrl: string, apiKey: string, model: string) =>
|
||||
request<LlmTestResult>(
|
||||
"/admin/test/llm",
|
||||
{ method: "POST", query: { api_url: apiUrl, api_key: apiKey, model } },
|
||||
),
|
||||
testLlmTools: (params: Record<string, string>) =>
|
||||
request<LlmToolsTestResult>("/admin/test/llm-tools", { method: "POST", query: params }),
|
||||
testEmbeddings: (params: Record<string, string>) =>
|
||||
request<EmbeddingsTestResult>("/admin/test/embeddings", { method: "POST", query: params }),
|
||||
probeDimension: (params: Record<string, string>) =>
|
||||
request<EmbeddingsProbeResult>(
|
||||
"/admin/test/embeddings/probe-dimension",
|
||||
{ method: "POST", query: params },
|
||||
),
|
||||
recreateCollections: () =>
|
||||
request<RecreateCollectionsResult>("/admin/embeddings/recreate-collections", { method: "POST" }),
|
||||
uploadIcon: (file: File, kind: "favicon" | "logo" | "og_image") => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("kind", kind);
|
||||
return request<UploadIconResult>("/admin/upload-icon", { method: "POST", formData: fd });
|
||||
},
|
||||
};
|
||||
|
||||
export const MiscApi = {
|
||||
health: () => request<HealthResponse>("/health"),
|
||||
};
|
||||
10
frontend/src/lib/cn.ts
Normal file
10
frontend/src/lib/cn.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/**
|
||||
* Merge Tailwind class names with conditional logic.
|
||||
* Combines clsx (conditional) + tailwind-merge (dedupe conflicting classes).
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
151
frontend/src/lib/sse.ts
Normal file
151
frontend/src/lib/sse.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { getAccessToken } from "./api";
|
||||
|
||||
export interface SseEvent<T = unknown> {
|
||||
id: string;
|
||||
event: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface SseHandlers {
|
||||
onEvent: (event: SseEvent) => void;
|
||||
onError?: (err: Event) => void;
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface SseController {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an SSE endpoint. Uses native EventSource.
|
||||
* Supports automatic reconnect with Last-Event-ID header (where supported by browser).
|
||||
*
|
||||
* Note: native EventSource cannot send Authorization header, so we pass the
|
||||
* token as a query string `access_token` (compatible with most SSE backends
|
||||
* that accept query token auth).
|
||||
*/
|
||||
export function subscribeSse(
|
||||
baseUrl: string,
|
||||
handlers: SseHandlers,
|
||||
opts: { withToken?: boolean } = {},
|
||||
): SseController {
|
||||
const { withToken = true } = opts;
|
||||
let es: EventSource | null = null;
|
||||
let closed = false;
|
||||
let lastEventId = "";
|
||||
let reconnectTimer: number | null = null;
|
||||
let reconnectDelay = 1000;
|
||||
|
||||
const buildUrl = (): string => {
|
||||
const url = new URL(baseUrl, window.location.origin);
|
||||
if (withToken) {
|
||||
const token = getAccessToken();
|
||||
if (token) url.searchParams.set("access_token", token);
|
||||
}
|
||||
if (lastEventId) url.searchParams.set("last_event_id", lastEventId);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const open = (): void => {
|
||||
if (closed) return;
|
||||
es = new EventSource(buildUrl(), { withCredentials: false });
|
||||
|
||||
es.onopen = () => {
|
||||
reconnectDelay = 1000;
|
||||
handlers.onOpen?.();
|
||||
};
|
||||
|
||||
es.onerror = (err) => {
|
||||
handlers.onError?.(err);
|
||||
if (es) es.close();
|
||||
es = null;
|
||||
if (closed) return;
|
||||
// Exponential backoff reconnect (max 30s)
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
||||
reconnectTimer = window.setTimeout(open, reconnectDelay);
|
||||
};
|
||||
|
||||
// Catch-all handler: parse all events through onmessage when no specific
|
||||
// addEventListener matches. We use the named-event approach via a generic
|
||||
// listener.
|
||||
es.onmessage = (ev: MessageEvent) => {
|
||||
// Default event (no `event:` field in stream)
|
||||
lastEventId = ev.lastEventId || lastEventId;
|
||||
handlers.onEvent({ id: ev.lastEventId, event: "message", data: safeParse(ev.data) });
|
||||
};
|
||||
|
||||
// We can't enumerate event types ahead of time; install a generic
|
||||
// listener by patching EventSource via addEventListener for common ones.
|
||||
// To support arbitrary event types, we override dispatch via a Proxy on
|
||||
// the prototype is overkill; instead we provide a helper to register
|
||||
// additional event types.
|
||||
for (const type of KNOWN_EVENTS) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
es.addEventListener(type, ((ev: MessageEvent) => {
|
||||
lastEventId = ev.lastEventId || lastEventId;
|
||||
handlers.onEvent({ id: ev.lastEventId, event: type, data: safeParse(ev.data) });
|
||||
}) as EventListener);
|
||||
}
|
||||
};
|
||||
|
||||
open();
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
closed = true;
|
||||
if (reconnectTimer) window.clearTimeout(reconnectTimer);
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
handlers.onClose?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function safeParse(raw: unknown): unknown {
|
||||
if (typeof raw !== "string") return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List of known SSE event types from the backend. We register listeners for
|
||||
* each so that the named-event form (`event: <type>`) is delivered with its
|
||||
* type label intact (EventSource only calls `onmessage` for unnamed events).
|
||||
*/
|
||||
export const KNOWN_EVENTS = [
|
||||
"ping",
|
||||
"error",
|
||||
"warning",
|
||||
"progress",
|
||||
"done",
|
||||
"phase_start",
|
||||
"phase_end",
|
||||
"tool_call",
|
||||
"llm_call_start",
|
||||
"llm_call_end",
|
||||
"scene_chunk",
|
||||
"scene_complete",
|
||||
"suggested_actions",
|
||||
"trigger_fired",
|
||||
"summary_generated",
|
||||
"iteration_complete",
|
||||
// world builder
|
||||
"step",
|
||||
"world_schema_generated",
|
||||
"environment_generated",
|
||||
"entities_generated",
|
||||
"intro_scene_chunk",
|
||||
"intro_scene_complete",
|
||||
// world editor
|
||||
"clarification",
|
||||
"change_proposed",
|
||||
"comment",
|
||||
"apply_changes",
|
||||
"discard_changes",
|
||||
] as const;
|
||||
@@ -1,14 +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";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@/i18n";
|
||||
import "@/index.css";
|
||||
import App from "@/App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("#root element not found");
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
70
frontend/src/pages/AdminPage.tsx
Normal file
70
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { SettingsPanel } from "@/components/admin/SettingsPanel";
|
||||
import { LlmLogsTable } from "@/components/admin/LlmLogsTable";
|
||||
import { UsersTable } from "@/components/admin/UsersTable";
|
||||
import { StatsPanel } from "@/components/admin/StatsPanel";
|
||||
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";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
export function AdminPage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [tab, setTab] = useState<Tab>("stats");
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-8 text-center">
|
||||
<p className="text-sm text-err">{t("admin.not_admin")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold text-fg">{t("admin.title")}</h1>
|
||||
</header>
|
||||
|
||||
<nav className="flex flex-wrap gap-1 border-b border-fg-dim/20">
|
||||
{TABS.map((tabDef) => (
|
||||
<button
|
||||
key={tabDef.id}
|
||||
onClick={() => setTab(tabDef.id)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
tab === 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}
|
||||
>
|
||||
{t(tabDef.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section>
|
||||
{tab === "settings" && <SettingsPanel />}
|
||||
{tab === "logs" && <LlmLogsTable />}
|
||||
{tab === "users" && <UsersTable />}
|
||||
{tab === "stats" && <StatsPanel />}
|
||||
{tab === "test" && <TestButtons />}
|
||||
{tab === "icons" && <IconsPanel />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { adminApi, uiApi } from "@/api";
|
||||
import type { LlmLog, SettingsOut } from "@/types";
|
||||
import { useUiStore } from "@/store/ui";
|
||||
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, Ban, CheckCircle2, Terminal, Wrench } 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);
|
||||
const [llmTest, setLlmTest] = useState<null | { ok: boolean; msg: string }>(null);
|
||||
const [testingLlm, setTestingLlm] = useState(false);
|
||||
const [llmToolsTest, setLlmToolsTest] = useState<null | { ok: boolean; msg: string }>(null);
|
||||
const [testingLlmTools, setTestingLlmTools] = useState(false);
|
||||
const [userActionError, setUserActionError] = useState("");
|
||||
|
||||
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);
|
||||
// If the admin changed the logo URL, refresh the public-UI cache so
|
||||
// the navbar/favicon update live without a full page reload.
|
||||
if ("ui.logo_url" in payload) {
|
||||
uiApi.resetCache();
|
||||
await useUiStore.getState().load(true);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Build LLM overrides from current form values (excluding masked api_key).
|
||||
// Used by both testLlm and testLlmTools so the operator can tweak base_url
|
||||
// / model / api_key in the form and test before saving.
|
||||
const buildLlmOverrides = (): Record<string, any> => {
|
||||
const overrides: Record<string, any> = {};
|
||||
for (const k of ["llm.base_url", "llm.api_key", "llm.model", "llm.request_timeout"]) {
|
||||
const v = values[k];
|
||||
if (v !== undefined && v !== null && !(typeof v === "string" && v.includes("***"))) {
|
||||
overrides[k] = v;
|
||||
}
|
||||
}
|
||||
return overrides;
|
||||
};
|
||||
|
||||
const testLlm = async () => {
|
||||
setError("");
|
||||
setTestingLlm(true);
|
||||
setLlmTest(null);
|
||||
try {
|
||||
const r = await adminApi.testLlm(buildLlmOverrides());
|
||||
if (r.ok) {
|
||||
setLlmTest({
|
||||
ok: true,
|
||||
msg: t("admin.llm_test_ok", {
|
||||
latency: r.latency_ms ?? 0,
|
||||
preview: (r.response_preview || "").slice(0, 80),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
setLlmTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_fail", { error: r.error || `(${r.error_type || "unknown"})` }),
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setLlmTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_fail", { error: err.response?.data?.detail || err.message }),
|
||||
});
|
||||
} finally {
|
||||
setTestingLlm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testLlmTools = async () => {
|
||||
setError("");
|
||||
setTestingLlmTools(true);
|
||||
setLlmToolsTest(null);
|
||||
try {
|
||||
const r = await adminApi.testLlmTools(buildLlmOverrides());
|
||||
if (r.ok && r.tool_calls_returned) {
|
||||
// Model returned a proper tool_call — function-calling works.
|
||||
setLlmToolsTest({
|
||||
ok: true,
|
||||
msg: t("admin.llm_test_tools_ok_with_call", {
|
||||
name: r.tool_call_name || "?",
|
||||
args: JSON.stringify(r.tool_call_args || {}),
|
||||
latency: r.latency_ms ?? 0,
|
||||
}),
|
||||
});
|
||||
} else if (r.ok && !r.tool_calls_returned) {
|
||||
// Model responded but did NOT use the tool — function-calling is NOT
|
||||
// supported. The fallback JSON parser will still work, but tool-based
|
||||
// flows (orchestrator, step-writer) will be unreliable.
|
||||
setLlmToolsTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_tools_ok_no_call", {
|
||||
text: (r.text || "").slice(0, 120),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
setLlmToolsTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_fail", { error: r.error || `(${r.error_type || "unknown"})` }),
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setLlmToolsTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_fail", { error: err.response?.data?.detail || err.message }),
|
||||
});
|
||||
} finally {
|
||||
setTestingLlmTools(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUserActive = async (userId: string, currentActive: boolean) => {
|
||||
setUserActionError("");
|
||||
try {
|
||||
const updated = await adminApi.setUserActive(userId, !currentActive);
|
||||
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, is_active: updated.is_active } : u)));
|
||||
} catch (err: any) {
|
||||
setUserActionError(err.response?.data?.detail || t("errors.unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* LLM connectivity tests — runs against current form values
|
||||
(so the operator can tweak base_url / model / api_key and
|
||||
test BEFORE saving). */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button variant="ghost" onClick={testLlm} disabled={testingLlm}>
|
||||
<Terminal size={14} className="mr-1" />
|
||||
{testingLlm ? t("admin.llm_test_testing") : t("admin.llm_test")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={testLlmTools} disabled={testingLlmTools}>
|
||||
<Wrench size={14} className="mr-1" />
|
||||
{testingLlmTools ? t("admin.llm_test_tools_testing") : t("admin.llm_test_tools")}
|
||||
</Button>
|
||||
{llmTest && (
|
||||
<span className={`text-xs ${llmTest.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{llmTest.msg}
|
||||
</span>
|
||||
)}
|
||||
{llmToolsTest && (
|
||||
<span className={`text-xs ${llmToolsTest.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{llmToolsTest.msg}
|
||||
</span>
|
||||
)}
|
||||
</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")} subtitle={t("admin.trigger_settings_desc")} />
|
||||
<CardBody>
|
||||
<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>
|
||||
<p className="text-xs text-ink-500 mt-2">{t("admin.triggers_enabled_desc")}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={t("admin.ui_settings")}
|
||||
subtitle={t("admin.ui_settings_desc")}
|
||||
/>
|
||||
<CardBody className="space-y-3">
|
||||
<Input
|
||||
label={t("admin.ui_logo_url")}
|
||||
value={values["ui.logo_url"] || ""}
|
||||
onChange={(e) => setValues({ ...values, "ui.logo_url": e.target.value })}
|
||||
placeholder="/logo.png"
|
||||
/>
|
||||
<p className="text-xs text-ink-500">{t("admin.ui_logo_url_hint")}</p>
|
||||
{/* Live preview so the admin sees the configured logo before saving. */}
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<span className="text-xs text-ink-400">{t("admin.ui_logo_preview")}:</span>
|
||||
<div className="w-10 h-10 rounded border border-ink-700 bg-ink-900 flex items-center justify-center overflow-hidden">
|
||||
{values["ui.logo_url"] ? (
|
||||
<img
|
||||
src={values["ui.logo_url"]}
|
||||
alt="preview"
|
||||
className="w-8 h-8 object-contain"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-ink-500">—</span>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
{userActionError && (
|
||||
<p className="text-sm text-red-400 mb-3">{userActionError}</p>
|
||||
)}
|
||||
<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 pr-3">Created</th>
|
||||
<th className="py-2 text-right">{t("admin.users_actions")}</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 pr-3 text-ink-400">
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{u.is_admin ? (
|
||||
<span className="text-ink-500 text-xs">—</span>
|
||||
) : u.is_active ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleUserActive(u.id, u.is_active)}
|
||||
>
|
||||
<Ban size={12} className="mr-1" />
|
||||
{t("admin.users_ban")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleUserActive(u.id, u.is_active)}
|
||||
>
|
||||
<CheckCircle2 size={12} className="mr-1" />
|
||||
{t("admin.users_unban")}
|
||||
</Button>
|
||||
)}
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/AdminRegisterPage.tsx
Normal file
119
frontend/src/pages/AdminRegisterPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, Link, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
export function AdminRegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const registerAdmin = useAuthStore((s) => s.registerAdmin);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const initialToken = searchParams.get("token") || "";
|
||||
const [token, setToken] = useState(initialToken);
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!token.trim()) next.token = t("errors.validation");
|
||||
if (!email.includes("@")) next.email = t("errors.validation");
|
||||
if (username.trim().length < 3) next.username = t("errors.validation");
|
||||
if (password.length < 8) next.password = t("errors.validation");
|
||||
if (password !== passwordConfirm) next.password_confirm = t("errors.validation");
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await registerAdmin({
|
||||
token: token.trim(),
|
||||
email: email.trim(),
|
||||
username: username.trim(),
|
||||
password,
|
||||
password_confirm: passwordConfirm,
|
||||
});
|
||||
pushToast("success", t("auth.admin_register_success"));
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : t("auth.register_failed");
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-md items-center p-4">
|
||||
<Card className="w-full" title={t("auth.admin_register_title")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
label={t("auth.admin_token")}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
error={errors.token}
|
||||
hint="Provided by an existing administrator."
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.email")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
error={errors.email}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
error={errors.username}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password_confirm")}
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password_confirm}
|
||||
/>
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{t("auth.register_button")}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-fg-muted">
|
||||
<Link to="/login" className="text-accent hover:underline">
|
||||
{t("auth.have_account")}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useUiStore } from "@/store/ui";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { BookOpen, Sparkles, Cog, Globe } from "lucide-react";
|
||||
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const logoUrl = useUiStore((s) => s.logoUrl);
|
||||
|
||||
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 overflow-hidden">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="logo"
|
||||
className="w-12 h-12 rounded object-contain"
|
||||
onError={(e) => {
|
||||
// Fall back to the BookOpen icon if the configured logo URL
|
||||
// fails to load (e.g. typo in admin settings, dead link).
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
const sib = (e.currentTarget as HTMLImageElement).nextElementSibling as HTMLElement | null;
|
||||
if (sib) sib.style.display = "block";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<BookOpen
|
||||
className="text-accent-500"
|
||||
size={32}
|
||||
style={{ display: logoUrl ? "none" : "block" }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +1,68 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { authApi } from "@/api";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card, CardBody } from "@/components/ui/Card";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { setAuth } = useAuthStore();
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
const [loginField, setLoginField] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
if (!loginField.trim() || !password) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// `login` accepts either email or username.
|
||||
const { access_token, user } = await authApi.login(login, password);
|
||||
setAuth(access_token, user);
|
||||
navigate("/dashboard");
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
await login({ login: loginField.trim(), password });
|
||||
pushToast("success", t("auth.login_success"));
|
||||
navigate("/worlds");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : t("auth.login_failed");
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSubmitting(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.login_or_email")}
|
||||
type="text"
|
||||
name="login"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
placeholder="alice / alice@example.com"
|
||||
/>
|
||||
<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">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-md items-center p-4">
|
||||
<Card className="w-full" title={t("auth.login_title")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
label={t("auth.login_field")}
|
||||
value={loginField}
|
||||
onChange={(e) => setLoginField(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{t("auth.login_button")}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-fg-muted">
|
||||
<Link to="/register" className="text-accent hover:underline">
|
||||
{t("auth.no_account")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardBody>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
233
frontend/src/pages/PlayPage.tsx
Normal file
233
frontend/src/pages/PlayPage.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSessionStore } from "@/stores/sessionStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
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";
|
||||
|
||||
export function PlayPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const world = useSessionStore((s) => s.world);
|
||||
const environment = useSessionStore((s) => s.environment);
|
||||
const nextActions = useSessionStore((s) => s.nextActions);
|
||||
const loading = useSessionStore((s) => s.loading);
|
||||
const error = useSessionStore((s) => s.error);
|
||||
const submitting = useSessionStore((s) => s.submitting);
|
||||
const sseStatus = useSessionStore((s) => s.sseStatus);
|
||||
const fetchState = useSessionStore((s) => s.fetchState);
|
||||
const sendAction = useSessionStore((s) => s.sendAction);
|
||||
const retry = useSessionStore((s) => s.retry);
|
||||
const rollback = useSessionStore((s) => s.rollback);
|
||||
const reset = useSessionStore((s) => s.reset);
|
||||
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
void fetchState(id).catch(() => pushToast("error", t("play.load_failed")));
|
||||
return () => {
|
||||
reset();
|
||||
};
|
||||
}, [id, fetchState, reset, pushToast, t]);
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
if (loading && !world) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-8 text-sm text-fg-muted">
|
||||
<Spinner /> {t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error && !world) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-err">{t("play.load_failed")}: {error}</p>
|
||||
<Button className="mt-3" variant="secondary" onClick={() => navigate("/worlds")}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!world) return null;
|
||||
|
||||
const player = environment?.player;
|
||||
const plotRails: PlotRail[] = Array.isArray(world.plot_rails) ? world.plot_rails : [];
|
||||
|
||||
const handleSend = (action: string) => {
|
||||
void sendAction(id, action, "manual").catch((err) => {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
});
|
||||
};
|
||||
|
||||
const handleSuggested = (action: string) => {
|
||||
void sendAction(id, action, "suggested").catch((err) => {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
});
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
void retry(id).catch((err) => {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
});
|
||||
};
|
||||
|
||||
const handleRollback = async () => {
|
||||
setRollbackOpen(false);
|
||||
try {
|
||||
await rollback(id);
|
||||
pushToast("success", t("play.rolled_back"));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-[calc(100vh-3.5rem)] max-w-7xl flex-col lg:flex-row gap-3 p-3">
|
||||
{/* 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>
|
||||
</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>
|
||||
</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>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleRetry} disabled={submitting}>
|
||||
{t("play.retry_last")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setRollbackOpen(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("play.rollback")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
{/* Chat area */}
|
||||
<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>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{world.current_time ? `${t("worlds.current_time")}: ${world.current_time}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<SseStatus status={sseStatus} />
|
||||
</header>
|
||||
<ChatView className="flex-1 min-h-0" />
|
||||
<footer className="border-t border-fg-dim/20 p-3">
|
||||
<ActionInput
|
||||
onSubmit={handleSend}
|
||||
onSuggestedClick={handleSuggested}
|
||||
submitting={submitting}
|
||||
suggestedActions={nextActions}
|
||||
placeholder={t("play.action_placeholder")}
|
||||
/>
|
||||
</footer>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
{/* Rollback confirm modal */}
|
||||
{rollbackOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setRollbackOpen(false)} />
|
||||
<div className="relative z-10 w-full max-w-sm rounded-lg border border-fg-dim/30 bg-bg-card p-4 shadow-2xl">
|
||||
<p className="text-sm text-fg">{t("play.rollback_confirm")}</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setRollbackOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleRollback}>
|
||||
{t("play.rollback")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<dt className="text-xs text-fg-dim">{label}</dt>
|
||||
<dd className="text-right text-sm text-fg">{text}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,105 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { authApi } from "@/api";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card, CardBody } from "@/components/ui/Card";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
export function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { setAuth } = useAuthStore();
|
||||
const register = useAuthStore((s) => s.register);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
const validate = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!email.includes("@")) next.email = t("errors.validation");
|
||||
if (username.trim().length < 3) next.username = t("errors.validation");
|
||||
if (password.length < 8) next.password = t("errors.validation");
|
||||
if (password !== passwordConfirm) next.password_confirm = t("errors.validation");
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
if (!validate()) return;
|
||||
setSubmitting(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"));
|
||||
await register({
|
||||
email: email.trim(),
|
||||
username: username.trim(),
|
||||
password,
|
||||
password_confirm: passwordConfirm,
|
||||
});
|
||||
pushToast("success", t("auth.register_success"));
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : t("auth.register_failed");
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSubmitting(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">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-md items-center p-4">
|
||||
<Card className="w-full" title={t("auth.register_title")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
label={t("auth.email")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
error={errors.email}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
error={errors.username}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password_confirm")}
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password_confirm}
|
||||
/>
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{t("auth.register_button")}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-fg-muted">
|
||||
<Link to="/login" className="text-accent hover:underline">
|
||||
{t("auth.have_account")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardBody>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
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, RefreshCw } 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 [lastFailedAction, setLastFailedAction] = useState<string | null>(null);
|
||||
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]);
|
||||
|
||||
// Auto-generate intro scene for fresh sessions (no messages yet).
|
||||
const introTriggeredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!id || introTriggeredRef.current) return;
|
||||
if (messages.length === 0 && !iterating && !error) {
|
||||
introTriggeredRef.current = true;
|
||||
runIntro();
|
||||
}
|
||||
}, [id, messages.length, iterating, error]);
|
||||
|
||||
const runIntro = async () => {
|
||||
if (!id) return;
|
||||
setIterating(true);
|
||||
setStatus(t("session.status_writing_scene"));
|
||||
setError("");
|
||||
try {
|
||||
await fetchEventSource(`/api/sessions/${id}/intro`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ session_id: id }),
|
||||
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 === "writing_scene") setStatus(t("session.status_writing_scene"));
|
||||
else setStatus(msg);
|
||||
} else if (eventName === "step_complete") {
|
||||
const stepMsg: Message = {
|
||||
id: data.message_id || `intro-${Date.now()}`,
|
||||
seq: data.seq || 1,
|
||||
role: "assistant",
|
||||
kind: "narrative_step",
|
||||
content: data.narrative || "",
|
||||
payload: { options: data.options || [], world_time: data.world_time, kind: "intro" },
|
||||
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 });
|
||||
}
|
||||
} else if (eventName === "error") {
|
||||
setError(data.message || t("session.error_iter"));
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
},
|
||||
onerror(err) {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
setError(String(err) || t("session.error_iter"));
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
setError(err.message || t("session.error_iter"));
|
||||
}
|
||||
};
|
||||
|
||||
const runIteration = async (overrideAction?: string) => {
|
||||
if (!id || iterating) return;
|
||||
const rawAction = overrideAction ?? actionText;
|
||||
if (!rawAction.trim()) return;
|
||||
setError("");
|
||||
setLastFailedAction(null);
|
||||
setIterating(true);
|
||||
setStatus(t("session.status_planning"));
|
||||
const action = rawAction.trim();
|
||||
if (overrideAction === undefined) setActionText("");
|
||||
|
||||
// Optimistic: show user action immediately (skip on retry if already shown)
|
||||
const alreadyShown = messages.some(
|
||||
(m) => m.kind === "player_action" && m.content === action && m.id?.startsWith("tmp-")
|
||||
);
|
||||
if (!alreadyShown) {
|
||||
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"));
|
||||
setLastFailedAction(action);
|
||||
} 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"));
|
||||
setLastFailedAction(action);
|
||||
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>
|
||||
)}
|
||||
{lastFailedAction ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 text-sm text-red-300 bg-red-950/40 border border-red-800/60 rounded-lg px-3 py-2">
|
||||
{error || t("session.error_iter")}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => runIteration(lastFailedAction)}
|
||||
disabled={iterating}
|
||||
className="self-end"
|
||||
>
|
||||
<Loader2 className={iterating ? "animate-spin mr-1" : "hidden"} size={14} />
|
||||
{!iterating && <RefreshCw size={14} className="mr-1" />}
|
||||
{iterating ? t("session.sending") : t("session.retry")}
|
||||
</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 && !lastFailedAction && <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;
|
||||
}
|
||||
@@ -1,266 +1,14 @@
|
||||
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[];
|
||||
}
|
||||
import { WorldBuilder } from "@/components/worlds/WorldBuilder";
|
||||
|
||||
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 === "ru" ? "ru" : "en");
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
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 className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold text-fg">{t("builder.title")}</h1>
|
||||
</header>
|
||||
<WorldBuilder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,305 +1,73 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import { worldsApi, sessionsApi } from "@/api";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import type { World } from "@/types";
|
||||
import { useWorldsStore } from "@/stores/worldsStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Textarea } from "@/components/ui/Input";
|
||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||
import { Play, Save, RotateCcw, Send, Sparkles, Loader2 } from "lucide-react";
|
||||
|
||||
interface EditorChatMessage {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ChatReply {
|
||||
ai_message: string;
|
||||
definition: Record<string, any> | null;
|
||||
changed: boolean;
|
||||
}
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { WorldEditor } from "@/components/worlds/WorldEditor";
|
||||
|
||||
export function WorldEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuthStore();
|
||||
|
||||
const [world, setWorld] = useState<World | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [definitionText, setDefinitionText] = useState("");
|
||||
const [stateText, setStateText] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// AI chat state
|
||||
const [chatMessages, setChatMessages] = useState<EditorChatMessage[]>([]);
|
||||
const [chatInput, setChatInput] = useState("");
|
||||
const [chatLoading, setChatLoading] = useState(false);
|
||||
const [pendingDefinition, setPendingDefinition] = useState<Record<string, any> | null>(null);
|
||||
const chatScrollRef = useRef<HTMLDivElement>(null);
|
||||
const world = useWorldsStore((s) => s.currentWorld);
|
||||
const loading = useWorldsStore((s) => s.currentLoading);
|
||||
const error = useWorldsStore((s) => s.currentError);
|
||||
const fetchWorld = useWorldsStore((s) => s.fetchWorld);
|
||||
const setCurrentWorld = useWorldsStore((s) => s.setCurrentWorld);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const w = await worldsApi.get(id);
|
||||
setWorld(w);
|
||||
setName(w.name);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
chatScrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [chatMessages, chatLoading]);
|
||||
|
||||
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, {
|
||||
name,
|
||||
definition,
|
||||
state,
|
||||
current_time: world.current_time,
|
||||
status: world.status === "draft" ? "ready" : world.status,
|
||||
});
|
||||
setWorld(updated);
|
||||
// Reset chat (definition changed -> stale context)
|
||||
setChatMessages([]);
|
||||
setPendingDefinition(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || err.response?.data?.detail || 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"));
|
||||
}
|
||||
};
|
||||
void fetchWorld(id).catch(() => {
|
||||
pushToast("error", t("worlds.not_found"));
|
||||
});
|
||||
return () => setCurrentWorld(null);
|
||||
}, [id, fetchWorld, setCurrentWorld, pushToast, t]);
|
||||
|
||||
const sendChatMessage = async () => {
|
||||
if (!id || !chatInput.trim() || chatLoading) return;
|
||||
const msg = chatInput.trim();
|
||||
setChatInput("");
|
||||
setChatMessages((prev) => [...prev, { role: "user", text: msg }]);
|
||||
setChatLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const { data } = await axios.post<ChatReply>(
|
||||
`/api/worlds/${id}/chat`,
|
||||
{ message: msg },
|
||||
{ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setChatMessages((prev) => [...prev, { role: "assistant", text: data.ai_message }]);
|
||||
if (data.definition) {
|
||||
setPendingDefinition(data.definition);
|
||||
setDefinitionText(JSON.stringify(data.definition, null, 2));
|
||||
}
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail || err.message || t("errors.unknown");
|
||||
setChatMessages((prev) => [...prev, { role: "assistant", text: `⚠️ ${detail}` }]);
|
||||
} finally {
|
||||
setChatLoading(false);
|
||||
}
|
||||
};
|
||||
if (!id) {
|
||||
return <p className="p-4 text-sm text-err">{t("worlds.not_found")}</p>;
|
||||
}
|
||||
|
||||
const resetChat = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await axios.post(`/api/worlds/${id}/chat/reset`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
setChatMessages([]);
|
||||
setPendingDefinition(null);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
}
|
||||
};
|
||||
if (loading && !world) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-8 text-sm text-fg-muted">
|
||||
<Spinner /> {t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const applyPendingDefinition = () => {
|
||||
if (!pendingDefinition) return;
|
||||
setDefinitionText(JSON.stringify(pendingDefinition, null, 2));
|
||||
setPendingDefinition(null);
|
||||
};
|
||||
if (error && !world) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-err">{t("worlds.not_found")}: {error}</p>
|
||||
<Button className="mt-3" variant="secondary" onClick={() => navigate("/worlds")}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
if (!world) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
<h1 className="text-xl font-serif text-ink-100 flex-1 min-w-0 truncate">
|
||||
{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 className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1>
|
||||
<p className="text-sm text-fg-muted">{world.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
|
||||
|
||||
{/* World name row */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader title={t("worlds.name")} />
|
||||
<CardBody>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={save} disabled={saving || !name.trim()}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin mr-1" /> : <Save size={14} className="mr-1" />}
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* AI chat panel (1 col) */}
|
||||
<Card className="lg:col-span-1 flex flex-col">
|
||||
<CardHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-accent-400" />
|
||||
{t("worlds.editor_chat_title")}
|
||||
</span> as any
|
||||
}
|
||||
subtitle={t("worlds.editor_chat_desc")}
|
||||
/>
|
||||
<CardBody className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto space-y-3 mb-3 max-h-[55vh]">
|
||||
{chatMessages.length === 0 && (
|
||||
<div className="text-xs text-ink-500 italic text-center py-6">
|
||||
{t("worlds.editor_chat_empty")}
|
||||
</div>
|
||||
)}
|
||||
{chatMessages.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-xl p-2.5 text-xs whitespace-pre-wrap ${
|
||||
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-[10px] text-ink-400 mb-1">
|
||||
{m.role === "user" ? t("worlds.editor_you") : "AI"}
|
||||
</div>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{chatLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-ink-800 border border-ink-700 rounded-xl p-2.5 text-ink-400 text-xs pulse-soft flex items-center gap-2">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatScrollRef} />
|
||||
</div>
|
||||
|
||||
{pendingDefinition && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-accent-500/10 border border-accent-500/40 text-xs text-accent-200 flex items-center justify-between gap-2">
|
||||
<span>{t("worlds.editor_pending_defn")}</span>
|
||||
<Button size="sm" variant="secondary" onClick={applyPendingDefinition}>
|
||||
{t("worlds.editor_apply")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
placeholder={t("worlds.editor_chat_ph")}
|
||||
disabled={chatLoading}
|
||||
rows={2}
|
||||
className="min-h-[60px] flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
sendChatMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button size="sm" onClick={sendChatMessage} disabled={chatLoading || !chatInput.trim()}>
|
||||
<Send size={14} />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={resetChat} disabled={chatLoading} title={t("worlds.editor_reset")}>
|
||||
<RotateCcw size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* JSON editors (2 cols) */}
|
||||
<div className="lg:col-span-2 grid grid-cols-1 md: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-[55vh] 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-[55vh] 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>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
|
||||
{t("worlds.cancel")}
|
||||
<Button variant="secondary" onClick={() => navigate(`/worlds/${world.id}/play`)}>
|
||||
{t("worlds.play")}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<WorldEditor
|
||||
world={world}
|
||||
onWorldUpdated={(w) => setCurrentWorld(w)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
100
frontend/src/pages/WorldsListPage.tsx
Normal file
100
frontend/src/pages/WorldsListPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWorldsStore } from "@/stores/worldsStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { WorldListItem } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { WorldCard } from "@/components/worlds/WorldCard";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
|
||||
export function WorldsListPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const list = useWorldsStore((s) => s.list);
|
||||
const loading = useWorldsStore((s) => s.loading);
|
||||
const error = useWorldsStore((s) => s.error);
|
||||
const fetchList = useWorldsStore((s) => s.fetchList);
|
||||
const removeWorld = useWorldsStore((s) => s.removeWorld);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const [toDelete, setToDelete] = useState<WorldListItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!toDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await removeWorld(toDelete.id);
|
||||
pushToast("success", t("worlds.deleted"));
|
||||
setToDelete(null);
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : t("worlds.delete_failed"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-fg">{t("worlds.title")}</h1>
|
||||
<Button onClick={() => navigate("/worlds/new")}>{t("worlds.create_new")}</Button>
|
||||
</header>
|
||||
|
||||
{loading && list.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<p className="text-sm text-err">{t("worlds.load_failed")}: {error}</p>
|
||||
<Button className="mt-2" variant="secondary" onClick={() => void fetchList()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</Card>
|
||||
) : list.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-fg-muted">{t("worlds.empty")}</p>
|
||||
<Button className="mt-3" onClick={() => navigate("/worlds/new")}>
|
||||
{t("worlds.create_new")}
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{list.map((w) => (
|
||||
<WorldCard key={w.id} world={w} onDelete={setToDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={!!toDelete}
|
||||
onClose={() => setToDelete(null)}
|
||||
title={t("worlds.delete")}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setToDelete(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" loading={deleting} onClick={confirmDelete}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg">{t("worlds.delete_confirm")}</p>
|
||||
{toDelete && (
|
||||
<p className="mt-2 text-sm font-semibold text-fg">{toDelete.name}</p>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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" }
|
||||
)
|
||||
);
|
||||
@@ -1,38 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { uiApi, type PublicUiSettings } from "@/api";
|
||||
|
||||
interface UiState {
|
||||
/** Logo URL (or path) to show in navbar, home page, and favicon. */
|
||||
logoUrl: string;
|
||||
/** True while the public UI settings are being fetched for the first time. */
|
||||
loading: boolean;
|
||||
/** Loads public UI settings from /api/settings/public (cached in api layer). */
|
||||
load: (force?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGO_URL = "/logo.png";
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
logoUrl: DEFAULT_LOGO_URL,
|
||||
loading: false,
|
||||
load: async (force = false) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const s: PublicUiSettings = await uiApi.getPublicSettings(force);
|
||||
const next = s.logo_url || DEFAULT_LOGO_URL;
|
||||
set({ logoUrl: next, loading: false });
|
||||
// Dynamically update the document favicon so a custom logo is reflected
|
||||
// in the browser tab without a page reload.
|
||||
try {
|
||||
const existing = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (existing && existing.href !== next) {
|
||||
existing.href = next;
|
||||
}
|
||||
} catch {
|
||||
// ignore — DOM might not be ready during SSR/early hydration
|
||||
}
|
||||
} catch {
|
||||
set({ logoUrl: DEFAULT_LOGO_URL, loading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
121
frontend/src/stores/authStore.ts
Normal file
121
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
AuthApi,
|
||||
ApiError,
|
||||
clearTokens,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
setUnauthorizedHandler,
|
||||
} from "@/lib/api";
|
||||
import type { LoginPayload, RegisterPayload, AdminRegisterPayload, User } from "@/types";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
status: "idle" | "loading" | "authenticated" | "unauthenticated";
|
||||
error: string | null;
|
||||
|
||||
bootstrap: () => Promise<void>;
|
||||
login: (payload: LoginPayload) => Promise<void>;
|
||||
register: (payload: RegisterPayload) => Promise<void>;
|
||||
registerAdmin: (payload: AdminRegisterPayload) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
setUser: (user: User | null) => void;
|
||||
setError: (err: string | null) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => {
|
||||
// Wire up the unauthorized handler so that any 401 from the API layer
|
||||
// clears the auth state automatically.
|
||||
setUnauthorizedHandler(() => {
|
||||
set({ user: null, token: null, status: "unauthenticated", error: null });
|
||||
});
|
||||
|
||||
return {
|
||||
user: null,
|
||||
token: getAccessToken(),
|
||||
status: "idle",
|
||||
error: null,
|
||||
|
||||
bootstrap: async () => {
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
set({ status: "unauthenticated", token: null, user: null });
|
||||
return;
|
||||
}
|
||||
set({ status: "loading" });
|
||||
try {
|
||||
const user = await AuthApi.me();
|
||||
set({ user, token, status: "authenticated" });
|
||||
} catch {
|
||||
clearTokens();
|
||||
set({ user: null, token: null, status: "unauthenticated" });
|
||||
}
|
||||
},
|
||||
|
||||
login: async (payload) => {
|
||||
set({ status: "loading", error: null });
|
||||
try {
|
||||
const data = await AuthApi.login(payload);
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
set({ user: data.user, token: data.access_token, status: "authenticated" });
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : "Login failed";
|
||||
set({ status: "unauthenticated", error: message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (payload) => {
|
||||
set({ status: "loading", error: null });
|
||||
try {
|
||||
await AuthApi.register(payload);
|
||||
set({ status: "unauthenticated", error: null });
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : "Registration failed";
|
||||
set({ status: "unauthenticated", error: message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
registerAdmin: async (payload) => {
|
||||
set({ status: "loading", error: null });
|
||||
try {
|
||||
await AuthApi.registerAdmin(payload);
|
||||
set({ status: "unauthenticated", error: null });
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : "Admin registration failed";
|
||||
set({ status: "unauthenticated", error: message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const refreshToken = getRefreshToken();
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await AuthApi.logout();
|
||||
}
|
||||
} catch {
|
||||
// ignore network errors on logout
|
||||
} finally {
|
||||
clearTokens();
|
||||
set({ user: null, token: null, status: "unauthenticated", error: null });
|
||||
}
|
||||
},
|
||||
|
||||
refreshUser: async () => {
|
||||
try {
|
||||
const user = await AuthApi.me();
|
||||
set({ user });
|
||||
} catch {
|
||||
// ignore — bootstrap will handle redirect
|
||||
}
|
||||
},
|
||||
|
||||
setUser: (user) => set({ user }),
|
||||
setError: (err) => set({ error: err }),
|
||||
};
|
||||
});
|
||||
308
frontend/src/stores/sessionStore.ts
Normal file
308
frontend/src/stores/sessionStore.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { create } from "zustand";
|
||||
import { SessionsApi } from "@/lib/api";
|
||||
import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse";
|
||||
import type {
|
||||
Environment,
|
||||
SessionState,
|
||||
Step,
|
||||
World,
|
||||
} from "@/types";
|
||||
|
||||
type SseStatus = "idle" | "connecting" | "open" | "error" | "closed";
|
||||
|
||||
interface StreamMessage {
|
||||
id: string;
|
||||
kind:
|
||||
| "scene_chunk"
|
||||
| "tool_call"
|
||||
| "llm_call_start"
|
||||
| "llm_call_end"
|
||||
| "phase_start"
|
||||
| "phase_end"
|
||||
| "progress"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| "iteration_complete"
|
||||
| "trigger_fired"
|
||||
| "summary_generated"
|
||||
| "suggested_actions";
|
||||
text?: string;
|
||||
tool?: string;
|
||||
toolResult?: unknown;
|
||||
toolSuccess?: boolean;
|
||||
phase?: string;
|
||||
phaseName?: string;
|
||||
step?: number;
|
||||
totalSteps?: number;
|
||||
message?: string;
|
||||
actions?: string[];
|
||||
}
|
||||
|
||||
interface SessionStateStore {
|
||||
world: World | null;
|
||||
environment: Environment | null;
|
||||
recentSteps: Step[];
|
||||
nextActions: string[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Streaming state
|
||||
sseStatus: SseStatus;
|
||||
streamingText: string;
|
||||
streamingStepId: string | null;
|
||||
streamMessages: StreamMessage[];
|
||||
submitting: boolean;
|
||||
|
||||
// SSE controllers
|
||||
_controller: SseController | null;
|
||||
|
||||
fetchState: (worldId: string) => Promise<void>;
|
||||
sendAction: (worldId: string, action: string, actionSource: "manual" | "suggested") => Promise<void>;
|
||||
retry: (worldId: string) => Promise<void>;
|
||||
rollback: (worldId: string) => Promise<void>;
|
||||
|
||||
subscribeIterate: (worldId: string, stepId: string) => void;
|
||||
closeStream: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string): void {
|
||||
// Mutable copy via set call
|
||||
const pushMessage = (m: StreamMessage) => {
|
||||
useSessionStore.setState((s) => ({ streamMessages: [...s.streamMessages, m] }));
|
||||
};
|
||||
const patch = (p: Partial<SessionStateStore>) => useSessionStore.setState(p);
|
||||
|
||||
switch (event.event) {
|
||||
case "ping":
|
||||
break;
|
||||
case "error": {
|
||||
const data = event.data as { message?: string; code?: string };
|
||||
pushMessage({ id: uid(), kind: "error", message: data?.message || "Stream error" });
|
||||
patch({ sseStatus: "error" });
|
||||
break;
|
||||
}
|
||||
case "warning": {
|
||||
const data = event.data as { message?: string };
|
||||
pushMessage({ id: uid(), kind: "warning", message: data?.message || "Warning" });
|
||||
break;
|
||||
}
|
||||
case "progress": {
|
||||
const d = event.data as { phase?: string; step?: number; total_steps?: number; message?: string };
|
||||
pushMessage({
|
||||
id: uid(),
|
||||
kind: "progress",
|
||||
phase: d.phase,
|
||||
step: d.step,
|
||||
totalSteps: d.total_steps,
|
||||
message: d.message,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "phase_start": {
|
||||
const d = event.data as { phase: string; name: string };
|
||||
pushMessage({ id: uid(), kind: "phase_start", phase: d.phase, phaseName: d.name });
|
||||
break;
|
||||
}
|
||||
case "phase_end": {
|
||||
const d = event.data as { phase: string; duration_ms: number };
|
||||
pushMessage({ id: uid(), kind: "phase_end", phase: d.phase, message: `${d.duration_ms}ms` });
|
||||
break;
|
||||
}
|
||||
case "tool_call": {
|
||||
const d = event.data as { tool: string; arguments: unknown; result: unknown; is_success: boolean };
|
||||
pushMessage({
|
||||
id: uid(),
|
||||
kind: "tool_call",
|
||||
tool: d.tool,
|
||||
toolResult: d.result,
|
||||
toolSuccess: d.is_success,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "llm_call_start": {
|
||||
const d = event.data as { stage: string; model: string };
|
||||
pushMessage({ id: uid(), kind: "llm_call_start", phase: d.stage, message: d.model });
|
||||
break;
|
||||
}
|
||||
case "llm_call_end": {
|
||||
const d = event.data as { stage: string; latency_ms: number; tokens?: number };
|
||||
pushMessage({
|
||||
id: uid(),
|
||||
kind: "llm_call_end",
|
||||
phase: d.stage,
|
||||
message: `${d.latency_ms}ms${d.tokens ? ` / ${d.tokens} tok` : ""}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "scene_chunk": {
|
||||
const d = event.data as { text: string };
|
||||
useSessionStore.setState((s) => ({ streamingText: s.streamingText + d.text }));
|
||||
break;
|
||||
}
|
||||
case "scene_complete": {
|
||||
const d = event.data as { text: string };
|
||||
useSessionStore.setState({ streamingText: d.text });
|
||||
break;
|
||||
}
|
||||
case "suggested_actions": {
|
||||
const d = event.data as { actions: string[] };
|
||||
pushMessage({ id: uid(), kind: "suggested_actions", actions: d.actions });
|
||||
useSessionStore.setState({ nextActions: d.actions });
|
||||
break;
|
||||
}
|
||||
case "trigger_fired": {
|
||||
const d = event.data as { trigger_id: string; event_type: string; summary: string };
|
||||
pushMessage({
|
||||
id: uid(),
|
||||
kind: "trigger_fired",
|
||||
message: `[${d.event_type}] ${d.summary}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "summary_generated": {
|
||||
pushMessage({ id: uid(), kind: "summary_generated" });
|
||||
break;
|
||||
}
|
||||
case "iteration_complete": {
|
||||
pushMessage({ id: uid(), kind: "iteration_complete" });
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
// Refresh session state from REST
|
||||
void useSessionStore.getState().fetchState(worldId);
|
||||
patch({
|
||||
sseStatus: "closed",
|
||||
submitting: false,
|
||||
streamingText: "",
|
||||
streamingStepId: null,
|
||||
});
|
||||
// Close the controller
|
||||
const c = useSessionStore.getState()._controller;
|
||||
if (c) {
|
||||
c.close();
|
||||
useSessionStore.setState({ _controller: null });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unknown event — ignore
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
||||
world: null,
|
||||
environment: null,
|
||||
recentSteps: [],
|
||||
nextActions: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
sseStatus: "idle",
|
||||
streamingText: "",
|
||||
streamingStepId: null,
|
||||
streamMessages: [],
|
||||
submitting: false,
|
||||
|
||||
_controller: null,
|
||||
|
||||
fetchState: async (worldId) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data: SessionState = await SessionsApi.state(worldId);
|
||||
set({
|
||||
world: data.world,
|
||||
environment: data.environment,
|
||||
recentSteps: data.recent_steps,
|
||||
nextActions: data.next_actions,
|
||||
loading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Failed to load session",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
sendAction: async (worldId, action, actionSource) => {
|
||||
set({ submitting: true, error: null, streamingText: "", streamMessages: [] });
|
||||
try {
|
||||
const res = await SessionsApi.iterate(worldId, action, actionSource);
|
||||
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
||||
get().subscribeIterate(worldId, res.step_id);
|
||||
} catch (err) {
|
||||
set({
|
||||
submitting: false,
|
||||
error: err instanceof Error ? err.message : "Failed to send action",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
retry: async (worldId) => {
|
||||
set({ submitting: true, error: null, streamingText: "", streamMessages: [] });
|
||||
try {
|
||||
const res = await SessionsApi.retry(worldId);
|
||||
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
||||
get().subscribeIterate(worldId, res.step_id);
|
||||
} catch (err) {
|
||||
set({
|
||||
submitting: false,
|
||||
error: err instanceof Error ? err.message : "Failed to retry",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
rollback: async (worldId) => {
|
||||
await SessionsApi.rollback(worldId);
|
||||
await get().fetchState(worldId);
|
||||
},
|
||||
|
||||
subscribeIterate: (worldId, stepId) => {
|
||||
const existing = get()._controller;
|
||||
if (existing) {
|
||||
existing.close();
|
||||
}
|
||||
const url = SessionsApi.iterateStreamUrl(worldId, stepId);
|
||||
const controller = subscribeSse(url, {
|
||||
onOpen: () => set({ sseStatus: "open" }),
|
||||
onError: () => set({ sseStatus: "error" }),
|
||||
onClose: () => set({ sseStatus: "closed" }),
|
||||
onEvent: (event) => handleEvent(get(), event, worldId),
|
||||
});
|
||||
set({ _controller: controller });
|
||||
},
|
||||
|
||||
closeStream: () => {
|
||||
const c = get()._controller;
|
||||
if (c) c.close();
|
||||
set({ _controller: null, sseStatus: "closed", submitting: false });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
const c = get()._controller;
|
||||
if (c) c.close();
|
||||
set({
|
||||
world: null,
|
||||
environment: null,
|
||||
recentSteps: [],
|
||||
nextActions: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
sseStatus: "idle",
|
||||
streamingText: "",
|
||||
streamingStepId: null,
|
||||
streamMessages: [],
|
||||
submitting: false,
|
||||
_controller: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
40
frontend/src/stores/toastStore.ts
Normal file
40
frontend/src/stores/toastStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from "zustand";
|
||||
import type { ToastItem, ToastKind } from "@/types";
|
||||
|
||||
interface ToastState {
|
||||
items: ToastItem[];
|
||||
push: (kind: ToastKind, message: string, timeout?: number) => string;
|
||||
remove: (id: string) => void;
|
||||
success: (message: string, timeout?: number) => string;
|
||||
error: (message: string, timeout?: number) => string;
|
||||
warning: (message: string, timeout?: number) => string;
|
||||
info: (message: string, timeout?: number) => string;
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set, get) => {
|
||||
const push = (kind: ToastKind, message: string, timeout = 5000): string => {
|
||||
const id = uid();
|
||||
const item: ToastItem = { id, kind, message, timeout };
|
||||
set({ items: [...get().items, item] });
|
||||
if (timeout > 0) {
|
||||
window.setTimeout(() => {
|
||||
get().remove(id);
|
||||
}, timeout);
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
return {
|
||||
items: [],
|
||||
push,
|
||||
remove: (id) => set({ items: get().items.filter((t) => t.id !== id) }),
|
||||
success: (m, t) => push("success", m, t),
|
||||
error: (m, t) => push("error", m, t),
|
||||
warning: (m, t) => push("warning", m, t),
|
||||
info: (m, t) => push("info", m, t),
|
||||
};
|
||||
});
|
||||
83
frontend/src/stores/uiStore.ts
Normal file
83
frontend/src/stores/uiStore.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { create } from "zustand";
|
||||
import type { Language } from "@/types";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
interface UiState {
|
||||
theme: Theme;
|
||||
language: Language;
|
||||
sidebarOpen: boolean;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
setLanguage: (lang: Language) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme): void {
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") root.classList.add("dark");
|
||||
else root.classList.remove("dark");
|
||||
try {
|
||||
localStorage.setItem("airpg_theme", theme);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function applyLanguage(lang: Language): void {
|
||||
void i18n.changeLanguage(lang);
|
||||
try {
|
||||
localStorage.setItem("airpg_lang", lang);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function initialTheme(): Theme {
|
||||
try {
|
||||
const stored = localStorage.getItem("airpg_theme") as Theme | null;
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Default: dark
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function initialLanguage(): Language {
|
||||
const detected = i18n.language as Language | undefined;
|
||||
if (detected === "en" || detected === "ru") return detected;
|
||||
return "en";
|
||||
}
|
||||
|
||||
const initialThemeValue = initialTheme();
|
||||
applyTheme(initialThemeValue);
|
||||
const initialLangValue = initialLanguage();
|
||||
applyLanguage(initialLangValue);
|
||||
|
||||
export const useUiStore = create<UiState>((set, get) => ({
|
||||
theme: initialThemeValue,
|
||||
language: initialLangValue,
|
||||
sidebarOpen: false,
|
||||
|
||||
setTheme: (theme) => {
|
||||
applyTheme(theme);
|
||||
set({ theme });
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
const next: Theme = get().theme === "dark" ? "light" : "dark";
|
||||
applyTheme(next);
|
||||
set({ theme: next });
|
||||
},
|
||||
|
||||
setLanguage: (lang) => {
|
||||
applyLanguage(lang);
|
||||
set({ language: lang });
|
||||
},
|
||||
|
||||
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
}));
|
||||
71
frontend/src/stores/worldsStore.ts
Normal file
71
frontend/src/stores/worldsStore.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { create } from "zustand";
|
||||
import { WorldsApi } from "@/lib/api";
|
||||
import type { World, WorldListItem } from "@/types";
|
||||
|
||||
interface WorldsState {
|
||||
list: WorldListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
currentWorld: World | null;
|
||||
currentLoading: boolean;
|
||||
currentError: string | null;
|
||||
|
||||
fetchList: (opts?: { page?: number; perPage?: number; statusFilter?: string }) => Promise<void>;
|
||||
fetchWorld: (id: string) => Promise<World>;
|
||||
clearCurrent: () => void;
|
||||
removeWorld: (id: string) => Promise<void>;
|
||||
setCurrentWorld: (w: World | null) => void;
|
||||
}
|
||||
|
||||
export const useWorldsStore = create<WorldsState>((set, get) => ({
|
||||
list: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
loading: false,
|
||||
error: null,
|
||||
currentWorld: null,
|
||||
currentLoading: false,
|
||||
currentError: null,
|
||||
|
||||
fetchList: async (opts = {}) => {
|
||||
const page = opts.page ?? get().page;
|
||||
const perPage = opts.perPage ?? get().perPage;
|
||||
const statusFilter = opts.statusFilter;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await WorldsApi.list({ page, per_page: perPage, status_filter: statusFilter });
|
||||
set({ list: data.items, total: data.total, page: data.page, perPage: data.per_page, loading: false });
|
||||
} catch (err) {
|
||||
set({ loading: false, error: err instanceof Error ? err.message : "Failed to load worlds" });
|
||||
}
|
||||
},
|
||||
|
||||
fetchWorld: async (id) => {
|
||||
set({ currentLoading: true, currentError: null });
|
||||
try {
|
||||
const world = await WorldsApi.get(id);
|
||||
set({ currentWorld: world, currentLoading: false });
|
||||
return world;
|
||||
} catch (err) {
|
||||
set({
|
||||
currentLoading: false,
|
||||
currentError: err instanceof Error ? err.message : "Failed to load world",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
clearCurrent: () => set({ currentWorld: null, currentError: null }),
|
||||
setCurrentWorld: (w) => set({ currentWorld: w }),
|
||||
|
||||
removeWorld: async (id) => {
|
||||
await WorldsApi.remove(id);
|
||||
// Remove from local list to avoid refetch
|
||||
const list = get().list.filter((w) => w.id !== id);
|
||||
set({ list, total: Math.max(0, get().total - 1) });
|
||||
},
|
||||
}));
|
||||
@@ -1,131 +1,452 @@
|
||||
// ===== Core domain types =====
|
||||
|
||||
export type Language = "en" | "ru";
|
||||
|
||||
export type WorldStatus =
|
||||
| "draft"
|
||||
| "building"
|
||||
| "ready"
|
||||
| "failed"
|
||||
| "archived";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
preferred_language: string;
|
||||
is_active?: boolean;
|
||||
created_at: string;
|
||||
last_login_at?: string | null;
|
||||
}
|
||||
|
||||
export interface TokenOut {
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
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 RegisterPayload {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
password_confirm: 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 AdminRegisterPayload extends RegisterPayload {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
login: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// ===== World types =====
|
||||
|
||||
export interface WorldListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
language: Language;
|
||||
status: WorldStatus;
|
||||
last_played_at: string | null;
|
||||
current_time: string | null;
|
||||
created_at: string;
|
||||
preview_player_name: string | null;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
export interface WorldRules {
|
||||
combat?: unknown;
|
||||
magic?: unknown;
|
||||
death?: unknown;
|
||||
advancement?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TimeSchema {
|
||||
unit?: string;
|
||||
current?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Schemas {
|
||||
[entityType: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
location?: string;
|
||||
time_of_day?: string;
|
||||
weather?: string;
|
||||
npcs?: Array<Record<string, unknown>>;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
player?: PlayerState;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
name?: string;
|
||||
hp?: number;
|
||||
max_hp?: number;
|
||||
level?: number;
|
||||
xp?: number;
|
||||
inventory?: Array<Record<string, unknown>>;
|
||||
conditions?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PlotRail {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
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;
|
||||
name: string;
|
||||
description: string;
|
||||
language: Language;
|
||||
rules: WorldRules;
|
||||
time_schema: TimeSchema;
|
||||
schemas: Schemas;
|
||||
environment_schema: unknown;
|
||||
environment: Environment;
|
||||
plot_rails: PlotRail[];
|
||||
current_time: string | null;
|
||||
status: WorldStatus;
|
||||
intro_scene: 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 {
|
||||
export interface CreateWorldPresetPayload {
|
||||
mode: "preset";
|
||||
preset_id: string;
|
||||
name: string;
|
||||
language: Language;
|
||||
player_name: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorldFormPayload {
|
||||
mode: "form";
|
||||
form_data: Record<string, unknown>;
|
||||
name: string;
|
||||
language: Language;
|
||||
player_name: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type CreateWorldPayload = CreateWorldPresetPayload | CreateWorldFormPayload;
|
||||
|
||||
export interface CreateWorldResponse {
|
||||
world_id: string;
|
||||
stream_url: string;
|
||||
}
|
||||
|
||||
export interface EditWorldPayload {
|
||||
instruction: string;
|
||||
}
|
||||
|
||||
export interface EditWorldResponse {
|
||||
stream_url: string;
|
||||
}
|
||||
|
||||
// ===== Step / session types =====
|
||||
|
||||
export interface Step {
|
||||
id: string;
|
||||
seq: number;
|
||||
role: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
payload: Record<string, any>;
|
||||
is_pinned: boolean;
|
||||
hidden: boolean;
|
||||
sequence_number: number;
|
||||
player_action: string | null;
|
||||
scene_text: string;
|
||||
suggested_actions: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GlossaryEntry {
|
||||
export interface SessionState {
|
||||
world: World;
|
||||
environment: Environment;
|
||||
recent_steps: Step[];
|
||||
next_actions: string[];
|
||||
}
|
||||
|
||||
export interface IterateResponse {
|
||||
stream_url: string;
|
||||
step_id: string;
|
||||
}
|
||||
|
||||
export interface RetryResponse {
|
||||
step_id: string;
|
||||
stream_url: string;
|
||||
}
|
||||
|
||||
export type ActionSource = "manual" | "suggested";
|
||||
|
||||
// ===== Preset types =====
|
||||
|
||||
export interface PresetListItem {
|
||||
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;
|
||||
language: Language;
|
||||
is_public: boolean;
|
||||
status: string;
|
||||
version: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SettingsOut {
|
||||
values: Record<string, any>;
|
||||
editable_keys: string[];
|
||||
export interface WorldPreset extends PresetListItem {
|
||||
rules: WorldRules;
|
||||
time_schema: TimeSchema;
|
||||
schemas: Schemas;
|
||||
environment_schema: unknown;
|
||||
environment_initial: unknown;
|
||||
}
|
||||
|
||||
export interface PresetPayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
language: Language;
|
||||
rules: WorldRules;
|
||||
time_schema: TimeSchema;
|
||||
schemas: Schemas;
|
||||
environment_schema: unknown;
|
||||
environment_initial: unknown;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
// ===== Admin types =====
|
||||
|
||||
export interface AdminSettingsResponse {
|
||||
settings: Record<string, string>;
|
||||
descriptions: Record<string, 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;
|
||||
world_id: string | null;
|
||||
stage: string;
|
||||
status: string;
|
||||
latency_ms: number | null;
|
||||
tokens: number | null;
|
||||
prompt: string | null;
|
||||
response: string | null;
|
||||
error: string | null;
|
||||
model: 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 interface LlmLogDetail extends LlmLog {
|
||||
messages?: unknown;
|
||||
tool_calls?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type IterationEventType =
|
||||
| "status"
|
||||
| "plan"
|
||||
| "tool_call"
|
||||
| "tool_result"
|
||||
| "narrative_chunk"
|
||||
| "step_complete"
|
||||
| "error"
|
||||
| "done";
|
||||
|
||||
export interface IterationEvent {
|
||||
type: IterationEventType;
|
||||
data: Record<string, any>;
|
||||
export interface AdminStats {
|
||||
users: number;
|
||||
worlds: number;
|
||||
steps: number;
|
||||
avg_llm_latency_ms: number | null;
|
||||
}
|
||||
|
||||
export interface LlmTestResult {
|
||||
ok: boolean;
|
||||
response?: string;
|
||||
model?: string;
|
||||
elapsed_ms?: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LlmToolsTestResult {
|
||||
ok: boolean;
|
||||
tool_calls?: unknown;
|
||||
has_tool_calls?: boolean;
|
||||
elapsed_ms?: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EmbeddingsTestResult {
|
||||
ok: boolean;
|
||||
dimension?: number;
|
||||
model?: string;
|
||||
first_5_values?: number[];
|
||||
elapsed_ms?: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EmbeddingsProbeResult {
|
||||
ok: boolean;
|
||||
dimension?: number;
|
||||
elapsed_ms?: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RecreateCollectionsResult {
|
||||
dropped: number;
|
||||
created: number;
|
||||
dimension: number;
|
||||
}
|
||||
|
||||
export interface UploadIconResult {
|
||||
ok: boolean;
|
||||
kind: "favicon" | "logo" | "og_image";
|
||||
url: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
db: string;
|
||||
qdrant: string;
|
||||
llm: string;
|
||||
embeddings: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// ===== SSE event payload types =====
|
||||
|
||||
export interface SseErrorPayload {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface SseProgressPayload {
|
||||
phase?: string;
|
||||
step?: number;
|
||||
total_steps?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SsePhaseStartPayload {
|
||||
phase: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SsePhaseEndPayload {
|
||||
phase: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export interface SseToolCallPayload {
|
||||
tool: string;
|
||||
arguments: unknown;
|
||||
result: unknown;
|
||||
is_success: boolean;
|
||||
}
|
||||
|
||||
export interface SseLlmCallStartPayload {
|
||||
stage: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface SseLlmCallEndPayload {
|
||||
stage: string;
|
||||
latency_ms: number;
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
export interface SseSceneChunkPayload {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SseSceneCompletePayload {
|
||||
text: string;
|
||||
delta_time: number;
|
||||
}
|
||||
|
||||
export interface SseSuggestedActionsPayload {
|
||||
actions: string[];
|
||||
}
|
||||
|
||||
export interface SseTriggerFiredPayload {
|
||||
trigger_id: string;
|
||||
event_type: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface SseSummaryGeneratedPayload {
|
||||
summary_id: string;
|
||||
message_range: [number, number];
|
||||
}
|
||||
|
||||
export interface SseIterationCompletePayload {
|
||||
step_id: string;
|
||||
sequence_number: number;
|
||||
}
|
||||
|
||||
export interface SseDonePayload<T = unknown> {
|
||||
result: T;
|
||||
}
|
||||
|
||||
// World builder
|
||||
export interface SseBuilderStepPayload {
|
||||
step: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SseWorldSchemaGeneratedPayload {
|
||||
schemas: Schemas;
|
||||
environment_schema: unknown;
|
||||
}
|
||||
|
||||
export interface SseEnvironmentGeneratedPayload {
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
export interface SseEntitiesGeneratedPayload {
|
||||
world_id: string;
|
||||
}
|
||||
|
||||
export interface SseIntroSceneChunkPayload {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SseIntroSceneCompletePayload {
|
||||
text: string;
|
||||
delta_time: number;
|
||||
current_time: string;
|
||||
}
|
||||
|
||||
// World editor
|
||||
export interface SseClarificationPayload {
|
||||
question: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface SseChangeProposedPayload {
|
||||
diff: unknown;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface SseCommentPayload {
|
||||
text: string;
|
||||
}
|
||||
|
||||
// ===== Toast types =====
|
||||
|
||||
export type ToastKind = "info" | "success" | "error" | "warning";
|
||||
|
||||
export interface ToastItem {
|
||||
id: string;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user