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;