diff --git a/public/admin.css b/public/admin.css index 8f8cc33..8d86e9b 100644 --- a/public/admin.css +++ b/public/admin.css @@ -706,6 +706,20 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } .message .by { font-size: 12px; color: var(--muted); margin-bottom: 4px; } .message p { margin: 0; white-space: pre-wrap; } .message a { color: var(--brand); display: inline-block; margin-top: 6px; } +.translation { + margin-top: 7px; + padding: 8px 9px; + border-left: 3px solid var(--brand); + border-radius: 6px; + background: #f4faf7; +} +.translation span { + display: block; + margin-bottom: 4px; + color: var(--muted); + font-size: 11px; + font-weight: 700; +} .read-receipt { align-self: flex-end; margin-top: -4px; diff --git a/public/admin.html b/public/admin.html index e11b349..4f4ad30 100644 --- a/public/admin.html +++ b/public/admin.html @@ -165,6 +165,20 @@ +
+ AI preklady + + + +
diff --git a/public/admin.js b/public/admin.js index 420b1d5..de5e321 100644 --- a/public/admin.js +++ b/public/admin.js @@ -79,6 +79,9 @@ $("#adminSoundRepeat").addEventListener("change", (event) => { $("#adminSoundTest").addEventListener("click", () => { playNotifySound({ ignoreMuted: true }); }); +$("#translationEnabled").addEventListener("change", saveTranslationSettings); +$("#operatorLanguage").addEventListener("change", saveTranslationSettings); +$("#translationModel").addEventListener("change", saveTranslationSettings); document.querySelectorAll("[data-settings-tab]").forEach((button) => { button.addEventListener("click", () => { const tab = button.dataset.settingsTab; @@ -385,6 +388,21 @@ function renderSite() { form.messageBadge.checked = s.notifications?.badgeOnAdminReply !== false; form.messageLabel.checked = s.notifications?.labelOnAdminReply !== false; 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"; +} + +async function saveTranslationSettings() { + if (!state.site) return; + const settings = structuredClone(state.site.settings); + settings.translations = { + ...(settings.translations || {}), + enabled: $("#translationEnabled").checked, + operatorLanguage: $("#operatorLanguage").value, + model: $("#translationModel").value.trim() || "gpt-5.6-luna" + }; + await saveSite({ settings, isOnline: state.site.isOnline }); } function renderConversations() { @@ -511,13 +529,28 @@ function renderActive() { $("#messages").innerHTML = messages.map((message) => `
${escapeHtml(message.senderName || message.senderType)} ยท ${escapeHtml(message.createdAt)}
- ${message.body ? `

${escapeHtml(message.body)}

` : ""} + ${renderMessageBody(message)} ${renderAttachments(message.attachments)}
`).join("") + adminReadReceipt(conversation, messages); $("#messages").scrollTop = $("#messages").scrollHeight; } +function renderMessageBody(message) { + const original = message.body ? `

${escapeHtml(message.body)}

` : ""; + if (!message.translatedBody) return original; + const label = message.senderType === "visitor" + ? `Preklad do ${escapeHtml(message.translatedLanguage || "")}` + : `Odeslano zakaznikovi ${escapeHtml(message.translatedLanguage || "")}`; + return ` + ${original} +
+ ${label} +

${escapeHtml(message.translatedBody)}

+
+ `; +} + async function renameVisitor(visitorToken) { if (!state.active?.conversation || !visitorToken) return; const currentName = conversationVisitorName(state.active.conversation); diff --git a/public/widget.js b/public/widget.js index 900da89..dfc8797 100644 --- a/public/widget.js +++ b/public/widget.js @@ -356,7 +356,8 @@ messages.innerHTML = ""; let lastAdminName = null; for (const message of data.messages) { - addMessage(messages, message.senderType, message.senderName, message.body, message.attachments || [], lastAdminName); + const body = message.senderType === "admin" ? (message.translatedBody || message.body) : message.body; + addMessage(messages, message.senderType, message.senderName, body, message.attachments || [], lastAdminName); if (message.senderType === "admin") lastAdminName = message.senderName || "Operator"; else lastAdminName = null; } diff --git a/server.js b/server.js index 209d03f..bce0de6 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,8 @@ const PORT = Number(process.env.PORT || 3400); const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`).replace(/\/$/, ""); 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 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; @@ -141,6 +143,7 @@ function initDb() { timezone TEXT, ip TEXT, country_code TEXT, + customer_language TEXT, vpn_risk INTEGER NOT NULL DEFAULT 0, visitor_seen_at TEXT, last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -154,6 +157,10 @@ function initDb() { sender_type TEXT NOT NULL, sender_name TEXT, client_message_id TEXT, + original_language TEXT, + translated_body TEXT, + translated_language TEXT, + translation_status TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -212,7 +219,12 @@ function initDb() { db.prepare("UPDATE visitor_sessions SET last_activity_at = last_seen_at WHERE last_activity_at IS NULL").run(); } ensureColumn("conversations", "visitor_seen_at", "TEXT"); + ensureColumn("conversations", "customer_language", "TEXT"); ensureColumn("messages", "client_message_id", "TEXT"); + ensureColumn("messages", "original_language", "TEXT"); + ensureColumn("messages", "translated_body", "TEXT"); + ensureColumn("messages", "translated_language", "TEXT"); + ensureColumn("messages", "translation_status", "TEXT"); db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_client_id ON messages(conversation_id, client_message_id) WHERE client_message_id IS NOT NULL"); } @@ -280,6 +292,11 @@ function defaultSettings() { badgeOnAdminReply: true, labelOnAdminReply: true, wiggleOnAdminReply: true + }, + translations: { + enabled: false, + operatorLanguage: "cs", + model: OPENAI_TRANSLATION_MODEL } }; } @@ -560,9 +577,10 @@ async function createConversation(req, res) { const messageId = insertMessage(conversationId, "visitor", info.name || "Visitor", String(body.message || "").trim(), clientMessageId); saveAttachments(conversationId, messageId, body.attachments || []); - return conversationId; + return { conversationId, messageId }; }); const insert = insertConversation(); + await translateStoredMessage(insert.conversationId, insert.messageId, "visitor", body); const conversation = findConversation(publicId); publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) }); @@ -584,6 +602,7 @@ async function createVisitorMessage(req, res, url) { const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message || "").trim(), clientMessageId); saveAttachments(conversation.id, messageId, body.attachments || []); updateVisitorContext(conversation.id, body); + await translateStoredMessage(conversation.id, messageId, "visitor", body); markConversationUnread(conversation.id); bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status); const updated = findConversation(conversation.public_id); @@ -610,6 +629,7 @@ async function createAdminMessage(req, res, url) { if (!conversation) return json(res, 404, { error: "conversation_not_found" }); const messageId = insertMessage(conversation.id, "admin", req.adminUser.displayName || req.adminUser.username, String(body.message || "").trim()); saveAttachments(conversation.id, messageId, body.attachments || []); + await translateStoredMessage(conversation.id, messageId, "admin", body); bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status); markConversationSeen(conversation.id); const updated = findConversation(conversation.public_id); @@ -680,9 +700,9 @@ function publish(key, event) { function insertMessage(conversationId, senderType, senderName, body, clientMessageId = null) { return db.prepare(` - INSERT INTO messages (conversation_id, sender_type, sender_name, client_message_id, body) - VALUES (?, ?, ?, ?, ?) - `).run(conversationId, senderType, senderName, clientMessageId, body).lastInsertRowid; + INSERT INTO messages (conversation_id, sender_type, sender_name, client_message_id, body, translation_status) + VALUES (?, ?, ?, ?, ?, ?) + `).run(conversationId, senderType, senderName, clientMessageId, body, body ? "pending" : "skipped").lastInsertRowid; } function messageExists(conversationId, clientMessageId) { @@ -707,6 +727,124 @@ function normalizeClientMessageId(value) { return /^[a-zA-Z0-9_-]{8,80}$/.test(text) ? text : null; } +async function translateStoredMessage(conversationId, messageId, senderType, body) { + const message = db.prepare("SELECT * FROM messages WHERE id = ? AND conversation_id = ?").get(messageId, conversationId); + if (!message || !message.body.trim()) return markMessageTranslation(messageId, null, null, null, "skipped"); + + const conversation = findConversationById(conversationId); + const settings = getTranslationSettings(); + if (!settings.enabled) return markMessageTranslation(messageId, null, null, null, "disabled"); + if (!OPENAI_API_KEY) return markMessageTranslation(messageId, null, null, null, "missing_key"); + + const targetLanguage = senderType === "visitor" + ? settings.operatorLanguage + : normalizeLanguage(conversation.customer_language || conversation.language || body.language || settings.operatorLanguage); + if (!targetLanguage) return markMessageTranslation(messageId, null, null, null, "skipped"); + + try { + const result = await translateText(message.body, targetLanguage, settings.model); + const sourceLanguage = normalizeLanguage(result.sourceLanguage); + const translatedText = String(result.translatedText || "").trim(); + const translatedLanguage = normalizeLanguage(result.translatedLanguage || targetLanguage); + const sameLanguage = sourceLanguage && translatedLanguage && sourceLanguage === translatedLanguage; + + if (senderType === "visitor" && sourceLanguage) { + db.prepare("UPDATE conversations SET customer_language = COALESCE(?, customer_language), updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(sourceLanguage, conversationId); + } + + if (!translatedText || sameLanguage) { + return markMessageTranslation(messageId, sourceLanguage, null, translatedLanguage, sameLanguage ? "same_language" : "empty"); + } + markMessageTranslation(messageId, sourceLanguage, translatedText, translatedLanguage, "translated"); + } catch (error) { + console.error("translation_failed", error.message); + markMessageTranslation(messageId, null, null, targetLanguage, "failed"); + } +} + +function markMessageTranslation(messageId, sourceLanguage, translatedBody, translatedLanguage, status) { + db.prepare(` + UPDATE messages + SET original_language = ?, + translated_body = ?, + translated_language = ?, + translation_status = ? + WHERE id = ? + `).run(sourceLanguage, translatedBody, translatedLanguage, status, messageId); +} + +async function translateText(text, targetLanguage, model) { + const response = await fetch("https://api.openai.com/v1/responses", { + method: "POST", + headers: { + "Authorization": `Bearer ${OPENAI_API_KEY}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + model, + input: [ + { + role: "system", + content: "You translate live chat messages for an e-commerce support team. Return only compact JSON with keys sourceLanguage, translatedLanguage, translatedText. Preserve meaning, product terms, numbers, names, URLs, and tone. Do not answer the customer." + }, + { + role: "user", + content: JSON.stringify({ + targetLanguage, + text + }) + } + ] + }) + }); + if (!response.ok) throw new Error(`OpenAI HTTP ${response.status}`); + const data = await response.json(); + return parseTranslationJson(outputTextFromResponse(data)); +} + +function parseTranslationJson(value) { + try { + const text = String(value || "").trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim(); + const parsed = JSON.parse(text); + return { + sourceLanguage: parsed.sourceLanguage, + translatedLanguage: parsed.translatedLanguage, + translatedText: parsed.translatedText + }; + } catch { + throw new Error("OpenAI translation response was not valid JSON"); + } +} + +function outputTextFromResponse(data) { + if (data.output_text) return data.output_text; + const chunks = []; + for (const item of data.output || []) { + for (const content of item.content || []) { + if (content.type === "output_text" && content.text) chunks.push(content.text); + if (content.type === "text" && content.text) chunks.push(content.text); + } + } + return chunks.join(""); +} + +function getTranslationSettings() { + const site = getDefaultSite(); + const settings = parseJson(site.settings_json, defaultSettings()); + const translations = { ...defaultSettings().translations, ...(settings.translations || {}) }; + return { + enabled: Boolean(translations.enabled), + operatorLanguage: normalizeLanguage(translations.operatorLanguage) || "cs", + model: String(translations.model || OPENAI_TRANSLATION_MODEL).trim() || OPENAI_TRANSLATION_MODEL + }; +} + +function normalizeLanguage(value) { + const text = String(value || "").trim().toLowerCase().replace("_", "-"); + if (!text) return ""; + return text.split("-")[0].slice(0, 8); +} + function saveAttachments(conversationId, messageId, attachments) { for (const attachment of attachments.slice(0, 3)) { if (!attachment.dataBase64 || !attachment.name) continue; @@ -827,6 +965,10 @@ function conversationDetails(conversation) { senderType: message.sender_type, senderName: message.sender_name, body: message.body, + originalLanguage: message.original_language, + translatedBody: message.translated_body, + translatedLanguage: message.translated_language, + translationStatus: message.translation_status, createdAt: message.created_at, attachments: attachments .filter((item) => item.message_id === message.id) @@ -842,6 +984,7 @@ function formatConversationSummary(row) { siteKey: row.site_key, status: row.status, visitorToken: row.visitor_token, + customerLanguage: row.customer_language || "", visitorInitials: visitorInitials(row.visitor_token), visitorLabel: row.visitor_display_name || visitorLabel(row.visitor_token), visitorDefaultLabel: visitorLabel(row.visitor_token), @@ -892,6 +1035,7 @@ function formatSite(site) { const settings = parseJson(site.settings_json, defaultSettings()); settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) }; settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) }; + settings.translations = { ...defaultSettings().translations, ...(settings.translations || {}) }; return { id: site.id, siteKey: site.site_key, @@ -954,6 +1098,11 @@ function findConversation(publicId) { `).get(publicId); } +function findConversationById(id) { + const row = db.prepare("SELECT public_id FROM conversations WHERE id = ?").get(id); + return row ? findConversation(row.public_id) : null; +} + function markConversationSeen(conversationId) { db.prepare("UPDATE conversations SET admin_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(conversationId); }