Add OpenAI chat translation layer

This commit is contained in:
rajchales-monito
2026-07-29 20:32:57 +02:00
parent 4217c4d469
commit 46d18c69eb
5 changed files with 217 additions and 6 deletions
+153 -4
View File
@@ -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);
}