diff --git a/public/admin.css b/public/admin.css index 8d86e9b..77a1670 100644 --- a/public/admin.css +++ b/public/admin.css @@ -225,6 +225,24 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } width: auto; margin: 0; } +.effect-settings input[type="password"], +.effect-settings select { + width: 100%; +} +.effect-settings small { + color: var(--muted); + font-size: 11px; +} +.model-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} +.model-row button { + height: 38px; + padding: 0 10px; +} .list-tools { padding: 10px 14px; border-bottom: 1px solid var(--line); diff --git a/public/admin.html b/public/admin.html index 4f4ad30..69e7d1d 100644 --- a/public/admin.html +++ b/public/admin.html @@ -177,7 +177,16 @@ - + + diff --git a/public/admin.js b/public/admin.js index de5e321..9e7d1e3 100644 --- a/public/admin.js +++ b/public/admin.js @@ -18,7 +18,8 @@ const state = { soundRepeatTimer: null, attentionOn: false, originalTitle: document.title, - originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg" + originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg", + aiModels: [] }; const $ = (selector) => document.querySelector(selector); @@ -82,6 +83,8 @@ $("#adminSoundTest").addEventListener("click", () => { $("#translationEnabled").addEventListener("change", saveTranslationSettings); $("#operatorLanguage").addEventListener("change", saveTranslationSettings); $("#translationModel").addEventListener("change", saveTranslationSettings); +$("#openaiApiKey").addEventListener("change", saveTranslationSettings); +$("#refreshAiModels").addEventListener("click", loadAiModels); document.querySelectorAll("[data-settings-tab]").forEach((button) => { button.addEventListener("click", () => { const tab = button.dataset.settingsTab; @@ -284,6 +287,7 @@ async function showApp() { $("#loginView").classList.add("hidden"); $("#appView").classList.remove("hidden"); await loadBootstrap(); + await loadAiModels().catch(() => {}); await Promise.all([loadConversations(), loadVisitors()]); connectEvents(); } @@ -390,7 +394,11 @@ function renderSite() { form.messageWiggle.checked = s.notifications?.wiggleOnAdminReply !== false; $("#translationEnabled").checked = Boolean(s.translations?.enabled); $("#operatorLanguage").value = s.translations?.operatorLanguage || "cs"; - $("#translationModel").value = s.translations?.model || "gpt-5.6-luna"; + $("#openaiApiKey").value = ""; + $("#openaiApiKeyStatus").textContent = s.translations?.hasOpenaiApiKey + ? `Klic ulozeny (${s.translations.openaiApiKeyMasked || "sk-..."})` + : "Klic neni ulozeny."; + renderModelOptions(s.translations?.model || "gpt-5-mini"); } async function saveTranslationSettings() { @@ -400,9 +408,31 @@ async function saveTranslationSettings() { ...(settings.translations || {}), enabled: $("#translationEnabled").checked, operatorLanguage: $("#operatorLanguage").value, - model: $("#translationModel").value.trim() || "gpt-5.6-luna" + model: $("#translationModel").value.trim() || "gpt-5-mini" }; + const apiKey = $("#openaiApiKey").value.trim(); + if (apiKey) settings.translations.openaiApiKey = apiKey; await saveSite({ settings, isOnline: state.site.isOnline }); + $("#openaiApiKey").value = ""; + await loadAiModels().catch(() => {}); +} + +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); + state.aiModels = data?.models || fallbackAiModels(); + renderModelOptions(current); +} + +function renderModelOptions(current) { + const select = $("#translationModel"); + const models = [...new Set([current, ...state.aiModels, ...fallbackAiModels()].filter(Boolean))]; + select.innerHTML = models.map((model) => ``).join(""); + select.value = models.includes(current) ? current : models[0]; +} + +function fallbackAiModels() { + return ["gpt-5-mini", "gpt-5-nano", "gpt-5.1", "gpt-4.1-mini"]; } function renderConversations() { @@ -1019,8 +1049,13 @@ async function api(url, options = {}) { headers: { "Content-Type": "application/json" }, body: options.body ? JSON.stringify(options.body) : undefined }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json(); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(`HTTP ${response.status}`); + Object.assign(error, data); + throw error; + } + return data; } function debounce(fn, delay) { diff --git a/server.js b/server.js index bce0de6..1ede4e2 100644 --- a/server.js +++ b/server.js @@ -17,7 +17,7 @@ const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || `http://localhost:${PORT const AUTH_SECRET = process.env.AUTH_SECRET || "dev-secret-change-me"; const COOKIE_NAME = "mf_session"; const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; -const OPENAI_TRANSLATION_MODEL = process.env.OPENAI_TRANSLATION_MODEL || "gpt-5.6-luna"; +const OPENAI_TRANSLATION_MODEL = process.env.OPENAI_TRANSLATION_MODEL || "gpt-5-mini"; const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]); const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]); const MAX_ATTACHMENT_BYTES = 3_000_000; @@ -85,6 +85,7 @@ async function adminApi(req, res, url) { if (url.pathname === "/api/admin/bootstrap" && req.method === "GET") return adminBootstrap(res); 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/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); @@ -296,7 +297,8 @@ function defaultSettings() { translations: { enabled: false, operatorLanguage: "cs", - model: OPENAI_TRANSLATION_MODEL + model: OPENAI_TRANSLATION_MODEL, + openaiApiKey: "" } }; } @@ -435,7 +437,9 @@ function adminConversation(res, url) { async function updateSite(req, res) { const body = await readJson(req); const site = getDefaultSite(); - const settings = { ...JSON.parse(site.settings_json), ...(body.settings || {}) }; + const currentSettings = parseJson(site.settings_json, defaultSettings()); + const incomingSettings = body.settings || {}; + const settings = mergeSiteSettings(currentSettings, incomingSettings); db.prepare(` UPDATE sites SET is_online = ?, settings_json = ?, updated_at = CURRENT_TIMESTAMP @@ -446,6 +450,27 @@ async function updateSite(req, res) { return json(res, 200, { site: formatSite(updated), snippet: snippetFor(updated) }); } +async function adminAiModels(res) { + const settings = getTranslationSettings(); + const apiKey = settings.openaiApiKey; + if (!apiKey) return json(res, 400, { error: "missing_openai_api_key", models: fallbackTranslationModels() }); + try { + const response = await fetch("https://api.openai.com/v1/models", { + headers: { "Authorization": `Bearer ${apiKey}` } + }); + if (!response.ok) throw new Error(`OpenAI HTTP ${response.status}`); + const data = await response.json(); + const models = (data.data || []) + .map((item) => item.id) + .filter(isUsableTextModel) + .sort((a, b) => a.localeCompare(b)); + return json(res, 200, { models: models.length ? models : fallbackTranslationModels() }); + } catch (error) { + console.error("openai_models_failed", error.message); + return json(res, 502, { error: "openai_models_failed", models: fallbackTranslationModels() }); + } +} + async function updateConversationStatus(req, res, url) { const body = await readJson(req); const status = ["new", "open", "resolved", "spam"].includes(body.status) ? body.status : null; @@ -742,7 +767,7 @@ async function translateStoredMessage(conversationId, messageId, senderType, bod if (!targetLanguage) return markMessageTranslation(messageId, null, null, null, "skipped"); try { - const result = await translateText(message.body, targetLanguage, settings.model); + const result = await translateText(message.body, targetLanguage, settings.model, settings.openaiApiKey); const sourceLanguage = normalizeLanguage(result.sourceLanguage); const translatedText = String(result.translatedText || "").trim(); const translatedLanguage = normalizeLanguage(result.translatedLanguage || targetLanguage); @@ -773,11 +798,11 @@ function markMessageTranslation(messageId, sourceLanguage, translatedBody, trans `).run(sourceLanguage, translatedBody, translatedLanguage, status, messageId); } -async function translateText(text, targetLanguage, model) { +async function translateText(text, targetLanguage, model, apiKey) { const response = await fetch("https://api.openai.com/v1/responses", { method: "POST", headers: { - "Authorization": `Bearer ${OPENAI_API_KEY}`, + "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ @@ -828,6 +853,26 @@ function outputTextFromResponse(data) { return chunks.join(""); } +function mergeSiteSettings(currentSettings, incomingSettings) { + const settings = { ...currentSettings, ...incomingSettings }; + const currentTranslations = currentSettings.translations || {}; + const incomingTranslations = incomingSettings.translations || {}; + settings.translations = { + ...currentTranslations, + ...incomingTranslations + }; + if (!incomingTranslations.openaiApiKey) { + settings.translations.openaiApiKey = currentTranslations.openaiApiKey || ""; + } + if (isMaskedApiKey(incomingTranslations.openaiApiKey)) { + settings.translations.openaiApiKey = currentTranslations.openaiApiKey || ""; + } + settings.translations.openaiApiKey = normalizeOpenAiApiKey(settings.translations.openaiApiKey); + delete settings.translations.hasOpenaiApiKey; + delete settings.translations.openaiApiKeyMasked; + return settings; +} + function getTranslationSettings() { const site = getDefaultSite(); const settings = parseJson(site.settings_json, defaultSettings()); @@ -835,10 +880,43 @@ function getTranslationSettings() { return { enabled: Boolean(translations.enabled), operatorLanguage: normalizeLanguage(translations.operatorLanguage) || "cs", - model: String(translations.model || OPENAI_TRANSLATION_MODEL).trim() || OPENAI_TRANSLATION_MODEL + model: String(translations.model || OPENAI_TRANSLATION_MODEL).trim() || OPENAI_TRANSLATION_MODEL, + openaiApiKey: normalizeOpenAiApiKey(translations.openaiApiKey) || OPENAI_API_KEY }; } +function normalizeOpenAiApiKey(value) { + const text = String(value || "").trim(); + if (!text || isMaskedApiKey(text)) return ""; + return text; +} + +function isMaskedApiKey(value) { + return String(value || "").includes("..."); +} + +function maskApiKey(value) { + const text = normalizeOpenAiApiKey(value); + if (!text) return ""; + return `${text.slice(0, 7)}...${text.slice(-4)}`; +} + +function fallbackTranslationModels() { + return [ + OPENAI_TRANSLATION_MODEL, + "gpt-5.1", + "gpt-5-mini", + "gpt-5-nano", + "gpt-4.1-mini", + "gpt-4.1-nano" + ].filter(Boolean); +} + +function isUsableTextModel(id) { + return /^(gpt-|o[134])/.test(id) + && !/audio|realtime|transcribe|tts|image|search|embedding|moderation|codex/i.test(id); +} + function normalizeLanguage(value) { const text = String(value || "").trim().toLowerCase().replace("_", "-"); if (!text) return ""; @@ -1036,6 +1114,10 @@ function formatSite(site) { settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) }; settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) }; settings.translations = { ...defaultSettings().translations, ...(settings.translations || {}) }; + const storedOpenAiKey = settings.translations.openaiApiKey || OPENAI_API_KEY; + settings.translations.hasOpenaiApiKey = Boolean(storedOpenAiKey); + settings.translations.openaiApiKeyMasked = maskApiKey(storedOpenAiKey); + settings.translations.openaiApiKey = ""; return { id: site.id, siteKey: site.site_key,