Files
ai-rpg/frontend/src/pages/AdminPanelPage.tsx
Mikan 2167493887 fix
2026-06-19 17:32:21 +03:00

615 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { adminApi } from "@/api";
import type { LlmLog, SettingsOut } from "@/types";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
import { Save, ArrowLeft, Activity, Users, Zap, 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);
} 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>
<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);
}}
/>
);
}