diff --git a/public/admin.css b/public/admin.css index 22ddc63..91d17f5 100644 --- a/public/admin.css +++ b/public/admin.css @@ -753,6 +753,27 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } } .message.admin { align-self: flex-end; background: #ecf8f3; border-color: #c7e8dc; } .message .by { font-size: 12px; color: var(--muted); margin-bottom: 4px; } +.translation-meta { + display: inline-flex; + align-items: center; + margin-left: 4px; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid #d8e5df; + background: #f6faf8; + color: #416256; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} +.translation-meta.translated { border-color: #b9dfca; background: #eef8f2; color: #1e7a47; } +.translation-meta.failed { border-color: #f0b9b9; background: #fff1f1; color: #b42318; } +.translation-meta.missing_key, +.translation-meta.empty { border-color: #f1d29b; background: #fff7e8; color: #9a5b00; } +.translation-meta.disabled, +.translation-meta.skipped, +.translation-meta.pending, +.translation-meta.same_language { border-color: #d7dde2; background: #f5f7f8; color: #667085; } .message p { margin: 0; white-space: pre-wrap; } .message a { color: var(--brand); display: inline-block; margin-top: 6px; } .translation { diff --git a/public/admin.js b/public/admin.js index 44447b7..11348be 100644 --- a/public/admin.js +++ b/public/admin.js @@ -591,9 +591,47 @@ function renderActive() { ${renderAttachments(message.attachments)} `).join("") + adminReadReceipt(conversation, messages); + decorateTranslationMeta(messages); $("#messages").scrollTop = $("#messages").scrollHeight; } +function decorateTranslationMeta(messages) { + document.querySelectorAll("#messages .message .by").forEach((element, index) => { + const meta = translationMeta(messages[index]); + if (!meta) return; + const badge = document.createElement("span"); + badge.className = `translation-meta ${meta.status}`; + badge.textContent = meta.label; + if (meta.error) badge.title = meta.error; + element.append(" "); + element.append(badge); + }); +} + +function translationMeta(message) { + if (!message?.translationStatus) return null; + const status = String(message.translationStatus || ""); + const source = String(message.originalLanguage || "?").toUpperCase(); + const target = String(message.translatedLanguage || "?").toUpperCase(); + return { + status, + error: message.translationError || "", + label: translationStatusLabel(status, source, target) + }; +} + +function translationStatusLabel(status, source, target) { + if (status === "translated") return `preklad: ${source} -> ${target}`; + if (status === "same_language") return `preklad: ${source} = ${target}`; + if (status === "failed") return `preklad: ${source} -> ${target} - chyba`; + if (status === "missing_key") return "preklad: chybi klic"; + if (status === "disabled") return "preklad: vypnuto"; + if (status === "empty") return `preklad: ${source} -> ${target} - prazdny`; + if (status === "skipped") return "preklad: preskoceno"; + if (status === "pending") return "preklad: ceka"; + return `preklad: ${status}`; +} + function renderMessageBody(message) { const original = message.body ? `
${escapeHtml(message.body)}
` : ""; if (!message.translatedBody) return original; diff --git a/server.js b/server.js index 8391cdc..9d21228 100644 --- a/server.js +++ b/server.js @@ -163,6 +163,7 @@ function initDb() { translated_body TEXT, translated_language TEXT, translation_status TEXT, + translation_error TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -227,6 +228,7 @@ function initDb() { ensureColumn("messages", "translated_body", "TEXT"); ensureColumn("messages", "translated_language", "TEXT"); ensureColumn("messages", "translation_status", "TEXT"); + ensureColumn("messages", "translation_error", "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"); } @@ -776,17 +778,17 @@ function normalizeClientMessageId(value) { 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"); + if (!message || !message.body.trim()) return markMessageTranslation(messageId, null, null, null, "skipped", null); const conversation = findConversationById(conversationId); const settings = getTranslationSettings(); - if (!settings.enabled) return markMessageTranslation(messageId, null, null, null, "disabled"); - if (!settings.openaiApiKey) return markMessageTranslation(messageId, null, null, null, "missing_key"); + if (!settings.enabled) return markMessageTranslation(messageId, null, null, null, "disabled", null); + if (!settings.openaiApiKey) return markMessageTranslation(messageId, null, null, null, "missing_key", null); 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"); + if (!targetLanguage) return markMessageTranslation(messageId, null, null, null, "skipped", null); try { const result = await translateText(message.body, targetLanguage, settings.model, settings.openaiApiKey); @@ -800,24 +802,25 @@ async function translateStoredMessage(conversationId, messageId, senderType, bod } if (!translatedText || sameLanguage) { - return markMessageTranslation(messageId, sourceLanguage, null, translatedLanguage, sameLanguage ? "same_language" : "empty"); + return markMessageTranslation(messageId, sourceLanguage, null, translatedLanguage, sameLanguage ? "same_language" : "empty", null); } - markMessageTranslation(messageId, sourceLanguage, translatedText, translatedLanguage, "translated"); + markMessageTranslation(messageId, sourceLanguage, translatedText, translatedLanguage, "translated", null); } catch (error) { console.error("translation_failed", error.message); - markMessageTranslation(messageId, null, null, targetLanguage, "failed"); + markMessageTranslation(messageId, null, null, targetLanguage, "failed", error.message); } } -function markMessageTranslation(messageId, sourceLanguage, translatedBody, translatedLanguage, status) { +function markMessageTranslation(messageId, sourceLanguage, translatedBody, translatedLanguage, status, error) { db.prepare(` UPDATE messages SET original_language = ?, translated_body = ?, translated_language = ?, - translation_status = ? + translation_status = ?, + translation_error = ? WHERE id = ? - `).run(sourceLanguage, translatedBody, translatedLanguage, status, messageId); + `).run(sourceLanguage, translatedBody, translatedLanguage, status, truncateText(error, 500), messageId); } async function translateText(text, targetLanguage, model, apiKey) { @@ -948,6 +951,13 @@ function normalizeLanguage(value) { return text.split("-")[0].slice(0, 8); } +function truncateText(value, maxLength) { + const text = String(value || "").trim(); + if (!text) return null; + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 3)}...`; +} + function saveAttachments(conversationId, messageId, attachments) { for (const attachment of attachments.slice(0, 3)) { if (!attachment.dataBase64 || !attachment.name) continue; @@ -1072,6 +1082,7 @@ function conversationDetails(conversation) { translatedBody: message.translated_body, translatedLanguage: message.translated_language, translationStatus: message.translation_status, + translationError: message.translation_error, createdAt: message.created_at, attachments: attachments .filter((item) => item.message_id === message.id)