diff --git a/public/admin.css b/public/admin.css
index d544d99..22ddc63 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -261,6 +261,19 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
height: 38px;
padding: 0 10px;
}
+.ai-actions {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+.ai-actions button {
+ padding: 10px 12px;
+}
+.ai-actions button:last-child {
+ background: #edf3ef;
+ color: var(--ink);
+}
.list-tools {
padding: 10px 14px;
border-bottom: 1px solid var(--line);
diff --git a/public/admin.html b/public/admin.html
index f91d498..46a5ae2 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -192,6 +192,10 @@
+
+
+
+
diff --git a/public/admin.js b/public/admin.js
index 6762a89..44447b7 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -80,11 +80,13 @@ $("#adminSoundRepeat").addEventListener("change", (event) => {
$("#adminSoundTest").addEventListener("click", () => {
playNotifySound({ ignoreMuted: true });
});
-$("#translationEnabled").addEventListener("change", saveTranslationSettings);
-$("#operatorLanguage").addEventListener("change", saveTranslationSettings);
-$("#translationModel").addEventListener("change", saveTranslationSettings);
-$("#openaiApiKey").addEventListener("change", saveTranslationSettings);
+$("#translationEnabled").addEventListener("change", markAiSettingsDirty);
+$("#operatorLanguage").addEventListener("change", markAiSettingsDirty);
+$("#translationModel").addEventListener("change", markAiSettingsDirty);
+$("#openaiApiKey").addEventListener("input", markAiSettingsDirty);
$("#refreshAiModels").addEventListener("click", loadAiModels);
+$("#saveAiSettings").addEventListener("click", saveTranslationSettings);
+$("#testOpenAiKey").addEventListener("click", testOpenAiKey);
document.querySelectorAll("[data-settings-tab]").forEach((button) => {
button.addEventListener("click", () => {
const tab = button.dataset.settingsTab;
@@ -403,6 +405,7 @@ function renderSite() {
async function saveTranslationSettings() {
if (!state.site) return;
+ setOpenAiStatus("Ukladam AI nastaveni...");
const settings = structuredClone(state.site.settings);
settings.translations = {
...(settings.translations || {}),
@@ -414,9 +417,34 @@ async function saveTranslationSettings() {
if (apiKey) settings.translations.openaiApiKey = apiKey;
await saveSite({ settings, isOnline: state.site.isOnline });
$("#openaiApiKey").value = "";
+ setOpenAiStatus(state.site.settings.translations?.hasOpenaiApiKey
+ ? `Klic ulozeny (${state.site.settings.translations.openaiApiKeyMasked || "sk-..."})`
+ : "Klic neni ulozeny. Bez nej AI preklady nepobezi.");
await loadAiModels().catch(() => {});
}
+function markAiSettingsDirty() {
+ setOpenAiStatus("AI nastaveni ma neulozene zmeny.");
+}
+
+async function testOpenAiKey() {
+ setOpenAiStatus("Testuji OpenAI klic...");
+ const result = await api("/api/admin/ai/test", { method: "POST" }).catch((error) => error);
+ if (result?.ok) {
+ setOpenAiStatus(`Klic funguje (${result.key || "sk-..."}), dostupnych modelu: ${result.modelCount}.`);
+ await loadAiModels().catch(() => {});
+ return;
+ }
+ const message = result?.error === "missing_openai_api_key"
+ ? "Klic neni ulozeny. Nejdřív ho vloz a klikni na Ulozit AI nastaveni."
+ : `Test selhal (${result?.error || "neznamá chyba"}).`;
+ setOpenAiStatus(message);
+}
+
+function setOpenAiStatus(message) {
+ $("#openaiApiKeyStatus").textContent = message;
+}
+
async function loadAiModels() {
const current = $("#translationModel").value || state.site?.settings?.translations?.model || "gpt-5-mini";
const data = await api("/api/admin/ai/models").catch((error) => error?.models ? error : null);
diff --git a/server.js b/server.js
index 1ede4e2..883ec69 100644
--- a/server.js
+++ b/server.js
@@ -86,6 +86,7 @@ async function adminApi(req, res, url) {
if (url.pathname === "/api/admin/events" && req.method === "GET") return eventStream(req, res, url, "admin");
if (url.pathname === "/api/admin/site" && req.method === "PATCH") return updateSite(req, res);
if (url.pathname === "/api/admin/ai/models" && req.method === "GET") return adminAiModels(res);
+ if (url.pathname === "/api/admin/ai/test" && req.method === "POST") return adminAiTest(res);
if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(res);
if (url.pathname === "/api/admin/visitors" && req.method === "GET") return adminVisitors(res);
if (url.pathname.match(/^\/api\/admin\/visitors\/[^/]+$/) && req.method === "PATCH") return updateVisitorProfile(req, res, url);
@@ -471,6 +472,27 @@ async function adminAiModels(res) {
}
}
+async function adminAiTest(res) {
+ const settings = getTranslationSettings();
+ const apiKey = settings.openaiApiKey;
+ if (!apiKey) return json(res, 400, { ok: false, error: "missing_openai_api_key" });
+ try {
+ const response = await fetch("https://api.openai.com/v1/models", {
+ headers: { "Authorization": `Bearer ${apiKey}` }
+ });
+ if (!response.ok) return json(res, 400, { ok: false, error: `openai_http_${response.status}` });
+ const data = await response.json();
+ return json(res, 200, {
+ ok: true,
+ key: maskApiKey(apiKey),
+ modelCount: Array.isArray(data.data) ? data.data.length : 0
+ });
+ } catch (error) {
+ console.error("openai_test_failed", error.message);
+ return json(res, 502, { ok: false, error: "openai_test_failed" });
+ }
+}
+
async function updateConversationStatus(req, res, url) {
const body = await readJson(req);
const status = ["new", "open", "resolved", "spam"].includes(body.status) ? body.status : null;