2026-06-19 11:28:04 +03:00
|
|
|
|
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";
|
2026-06-19 17:32:21 +03:00
|
|
|
|
import { Save, ArrowLeft, Activity, Users, Zap, Ban, CheckCircle2, Terminal, Wrench } from "lucide-react";
|
2026-06-19 11:28:04 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-19 17:32:21 +03:00
|
|
|
|
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);
|
2026-06-19 16:31:45 +03:00
|
|
|
|
const [userActionError, setUserActionError] = useState("");
|
2026-06-19 11:28:04 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-19 17:32:21 +03:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-19 16:31:45 +03:00
|
|
|
|
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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-19 11:28:04 +03:00
|
|
|
|
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>
|
2026-06-19 17:32:21 +03:00
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
2026-06-19 11:28:04 +03:00
|
|
|
|
</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>
|
2026-06-19 16:31:45 +03:00
|
|
|
|
<CardHeader title={t("admin.trigger_settings")} subtitle={t("admin.trigger_settings_desc")} />
|
2026-06-19 11:28:04 +03:00
|
|
|
|
<CardBody>
|
2026-06-19 16:31:45 +03:00
|
|
|
|
<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 })}
|
2026-06-19 11:28:04 +03:00
|
|
|
|
/>
|
2026-06-19 16:31:45 +03:00
|
|
|
|
{t("admin.triggers_enabled")}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<p className="text-xs text-ink-500 mt-2">{t("admin.triggers_enabled_desc")}</p>
|
2026-06-19 11:28:04 +03:00
|
|
|
|
</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>
|
2026-06-19 16:31:45 +03:00
|
|
|
|
{userActionError && (
|
|
|
|
|
|
<p className="text-sm text-red-400 mb-3">{userActionError}</p>
|
|
|
|
|
|
)}
|
2026-06-19 11:28:04 +03:00
|
|
|
|
<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>
|
2026-06-19 16:31:45 +03:00
|
|
|
|
<th className="py-2 pr-3">Created</th>
|
|
|
|
|
|
<th className="py-2 text-right">{t("admin.users_actions")}</th>
|
2026-06-19 11:28:04 +03:00
|
|
|
|
</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>
|
2026-06-19 16:31:45 +03:00
|
|
|
|
<td className="py-2 pr-3 text-ink-400">
|
2026-06-19 11:28:04 +03:00
|
|
|
|
{new Date(u.created_at).toLocaleDateString()}
|
|
|
|
|
|
</td>
|
2026-06-19 16:31:45 +03:00
|
|
|
|
<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>
|
2026-06-19 11:28:04 +03:00
|
|
|
|
</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);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|