Add site OpenAI key and model picker
This commit is contained in:
@@ -225,6 +225,24 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
width: auto;
|
width: auto;
|
||||||
margin: 0;
|
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 {
|
.list-tools {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
|||||||
+10
-1
@@ -177,7 +177,16 @@
|
|||||||
<option value="de">Nemcina</option>
|
<option value="de">Nemcina</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>OpenAI model <input id="translationModel" placeholder="gpt-5.6-luna"></label>
|
<label>OpenAI API key
|
||||||
|
<input id="openaiApiKey" type="password" autocomplete="off" placeholder="sk-...">
|
||||||
|
<small id="openaiApiKeyStatus">Klic neni ulozeny.</small>
|
||||||
|
</label>
|
||||||
|
<label>OpenAI model
|
||||||
|
<span class="model-row">
|
||||||
|
<select id="translationModel"></select>
|
||||||
|
<button id="refreshAiModels" type="button">Obnovit</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+40
-5
@@ -18,7 +18,8 @@ const state = {
|
|||||||
soundRepeatTimer: null,
|
soundRepeatTimer: null,
|
||||||
attentionOn: false,
|
attentionOn: false,
|
||||||
originalTitle: document.title,
|
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);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
@@ -82,6 +83,8 @@ $("#adminSoundTest").addEventListener("click", () => {
|
|||||||
$("#translationEnabled").addEventListener("change", saveTranslationSettings);
|
$("#translationEnabled").addEventListener("change", saveTranslationSettings);
|
||||||
$("#operatorLanguage").addEventListener("change", saveTranslationSettings);
|
$("#operatorLanguage").addEventListener("change", saveTranslationSettings);
|
||||||
$("#translationModel").addEventListener("change", saveTranslationSettings);
|
$("#translationModel").addEventListener("change", saveTranslationSettings);
|
||||||
|
$("#openaiApiKey").addEventListener("change", saveTranslationSettings);
|
||||||
|
$("#refreshAiModels").addEventListener("click", loadAiModels);
|
||||||
document.querySelectorAll("[data-settings-tab]").forEach((button) => {
|
document.querySelectorAll("[data-settings-tab]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const tab = button.dataset.settingsTab;
|
const tab = button.dataset.settingsTab;
|
||||||
@@ -284,6 +287,7 @@ async function showApp() {
|
|||||||
$("#loginView").classList.add("hidden");
|
$("#loginView").classList.add("hidden");
|
||||||
$("#appView").classList.remove("hidden");
|
$("#appView").classList.remove("hidden");
|
||||||
await loadBootstrap();
|
await loadBootstrap();
|
||||||
|
await loadAiModels().catch(() => {});
|
||||||
await Promise.all([loadConversations(), loadVisitors()]);
|
await Promise.all([loadConversations(), loadVisitors()]);
|
||||||
connectEvents();
|
connectEvents();
|
||||||
}
|
}
|
||||||
@@ -390,7 +394,11 @@ function renderSite() {
|
|||||||
form.messageWiggle.checked = s.notifications?.wiggleOnAdminReply !== false;
|
form.messageWiggle.checked = s.notifications?.wiggleOnAdminReply !== false;
|
||||||
$("#translationEnabled").checked = Boolean(s.translations?.enabled);
|
$("#translationEnabled").checked = Boolean(s.translations?.enabled);
|
||||||
$("#operatorLanguage").value = s.translations?.operatorLanguage || "cs";
|
$("#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() {
|
async function saveTranslationSettings() {
|
||||||
@@ -400,9 +408,31 @@ async function saveTranslationSettings() {
|
|||||||
...(settings.translations || {}),
|
...(settings.translations || {}),
|
||||||
enabled: $("#translationEnabled").checked,
|
enabled: $("#translationEnabled").checked,
|
||||||
operatorLanguage: $("#operatorLanguage").value,
|
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 });
|
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) => `<option value="${escapeHtml(model)}">${escapeHtml(model)}</option>`).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() {
|
function renderConversations() {
|
||||||
@@ -1019,8 +1049,13 @@ async function api(url, options = {}) {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: options.body ? JSON.stringify(options.body) : undefined
|
body: options.body ? JSON.stringify(options.body) : undefined
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
const data = await response.json().catch(() => ({}));
|
||||||
return response.json();
|
if (!response.ok) {
|
||||||
|
const error = new Error(`HTTP ${response.status}`);
|
||||||
|
Object.assign(error, data);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function debounce(fn, delay) {
|
function debounce(fn, delay) {
|
||||||
|
|||||||
@@ -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 AUTH_SECRET = process.env.AUTH_SECRET || "dev-secret-change-me";
|
||||||
const COOKIE_NAME = "mf_session";
|
const COOKIE_NAME = "mf_session";
|
||||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || "";
|
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_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 ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
|
||||||
const MAX_ATTACHMENT_BYTES = 3_000_000;
|
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/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/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/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/conversations" && req.method === "GET") return adminConversations(res);
|
||||||
if (url.pathname === "/api/admin/visitors" && req.method === "GET") return adminVisitors(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);
|
if (url.pathname.match(/^\/api\/admin\/visitors\/[^/]+$/) && req.method === "PATCH") return updateVisitorProfile(req, res, url);
|
||||||
@@ -296,7 +297,8 @@ function defaultSettings() {
|
|||||||
translations: {
|
translations: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
operatorLanguage: "cs",
|
operatorLanguage: "cs",
|
||||||
model: OPENAI_TRANSLATION_MODEL
|
model: OPENAI_TRANSLATION_MODEL,
|
||||||
|
openaiApiKey: ""
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -435,7 +437,9 @@ function adminConversation(res, url) {
|
|||||||
async function updateSite(req, res) {
|
async function updateSite(req, res) {
|
||||||
const body = await readJson(req);
|
const body = await readJson(req);
|
||||||
const site = getDefaultSite();
|
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(`
|
db.prepare(`
|
||||||
UPDATE sites
|
UPDATE sites
|
||||||
SET is_online = ?, settings_json = ?, updated_at = CURRENT_TIMESTAMP
|
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) });
|
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) {
|
async function updateConversationStatus(req, res, url) {
|
||||||
const body = await readJson(req);
|
const body = await readJson(req);
|
||||||
const status = ["new", "open", "resolved", "spam"].includes(body.status) ? body.status : null;
|
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");
|
if (!targetLanguage) return markMessageTranslation(messageId, null, null, null, "skipped");
|
||||||
|
|
||||||
try {
|
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 sourceLanguage = normalizeLanguage(result.sourceLanguage);
|
||||||
const translatedText = String(result.translatedText || "").trim();
|
const translatedText = String(result.translatedText || "").trim();
|
||||||
const translatedLanguage = normalizeLanguage(result.translatedLanguage || targetLanguage);
|
const translatedLanguage = normalizeLanguage(result.translatedLanguage || targetLanguage);
|
||||||
@@ -773,11 +798,11 @@ function markMessageTranslation(messageId, sourceLanguage, translatedBody, trans
|
|||||||
`).run(sourceLanguage, translatedBody, translatedLanguage, status, messageId);
|
`).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", {
|
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${OPENAI_API_KEY}`,
|
"Authorization": `Bearer ${apiKey}`,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -828,6 +853,26 @@ function outputTextFromResponse(data) {
|
|||||||
return chunks.join("");
|
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() {
|
function getTranslationSettings() {
|
||||||
const site = getDefaultSite();
|
const site = getDefaultSite();
|
||||||
const settings = parseJson(site.settings_json, defaultSettings());
|
const settings = parseJson(site.settings_json, defaultSettings());
|
||||||
@@ -835,10 +880,43 @@ function getTranslationSettings() {
|
|||||||
return {
|
return {
|
||||||
enabled: Boolean(translations.enabled),
|
enabled: Boolean(translations.enabled),
|
||||||
operatorLanguage: normalizeLanguage(translations.operatorLanguage) || "cs",
|
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) {
|
function normalizeLanguage(value) {
|
||||||
const text = String(value || "").trim().toLowerCase().replace("_", "-");
|
const text = String(value || "").trim().toLowerCase().replace("_", "-");
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
@@ -1036,6 +1114,10 @@ function formatSite(site) {
|
|||||||
settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) };
|
settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) };
|
||||||
settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) };
|
settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) };
|
||||||
settings.translations = { ...defaultSettings().translations, ...(settings.translations || {}) };
|
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 {
|
return {
|
||||||
id: site.id,
|
id: site.id,
|
||||||
siteKey: site.site_key,
|
siteKey: site.site_key,
|
||||||
|
|||||||
Reference in New Issue
Block a user