Add OpenAI chat translation layer
This commit is contained in:
@@ -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 .by { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||||
.message p { margin: 0; white-space: pre-wrap; }
|
.message p { margin: 0; white-space: pre-wrap; }
|
||||||
.message a { color: var(--brand); display: inline-block; margin-top: 6px; }
|
.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 {
|
.read-receipt {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
margin-top: -4px;
|
margin-top: -4px;
|
||||||
|
|||||||
@@ -165,6 +165,20 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="setting-switch">Opakovat do otevreni chatu <input id="adminSoundRepeat" type="checkbox"></label>
|
<label class="setting-switch">Opakovat do otevreni chatu <input id="adminSoundRepeat" type="checkbox"></label>
|
||||||
<button id="adminSoundTest" type="button">Otestovat zvuk</button>
|
<button id="adminSoundTest" type="button">Otestovat zvuk</button>
|
||||||
|
<fieldset class="effect-settings">
|
||||||
|
<legend>AI preklady</legend>
|
||||||
|
<label><input id="translationEnabled" type="checkbox"> zapnout preklady</label>
|
||||||
|
<label>Jazyk operatora
|
||||||
|
<select id="operatorLanguage">
|
||||||
|
<option value="cs">Cestina</option>
|
||||||
|
<option value="sk">Slovenstina</option>
|
||||||
|
<option value="pl">Polstina</option>
|
||||||
|
<option value="en">Anglictina</option>
|
||||||
|
<option value="de">Nemcina</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>OpenAI model <input id="translationModel" placeholder="gpt-5.6-luna"></label>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+34
-1
@@ -79,6 +79,9 @@ $("#adminSoundRepeat").addEventListener("change", (event) => {
|
|||||||
$("#adminSoundTest").addEventListener("click", () => {
|
$("#adminSoundTest").addEventListener("click", () => {
|
||||||
playNotifySound({ ignoreMuted: true });
|
playNotifySound({ ignoreMuted: true });
|
||||||
});
|
});
|
||||||
|
$("#translationEnabled").addEventListener("change", saveTranslationSettings);
|
||||||
|
$("#operatorLanguage").addEventListener("change", saveTranslationSettings);
|
||||||
|
$("#translationModel").addEventListener("change", saveTranslationSettings);
|
||||||
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;
|
||||||
@@ -385,6 +388,21 @@ function renderSite() {
|
|||||||
form.messageBadge.checked = s.notifications?.badgeOnAdminReply !== false;
|
form.messageBadge.checked = s.notifications?.badgeOnAdminReply !== false;
|
||||||
form.messageLabel.checked = s.notifications?.labelOnAdminReply !== false;
|
form.messageLabel.checked = s.notifications?.labelOnAdminReply !== false;
|
||||||
form.messageWiggle.checked = s.notifications?.wiggleOnAdminReply !== 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() {
|
function renderConversations() {
|
||||||
@@ -511,13 +529,28 @@ function renderActive() {
|
|||||||
$("#messages").innerHTML = messages.map((message) => `
|
$("#messages").innerHTML = messages.map((message) => `
|
||||||
<article class="message ${message.senderType}">
|
<article class="message ${message.senderType}">
|
||||||
<div class="by">${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}</div>
|
<div class="by">${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}</div>
|
||||||
${message.body ? `<p>${escapeHtml(message.body)}</p>` : ""}
|
${renderMessageBody(message)}
|
||||||
${renderAttachments(message.attachments)}
|
${renderAttachments(message.attachments)}
|
||||||
</article>
|
</article>
|
||||||
`).join("") + adminReadReceipt(conversation, messages);
|
`).join("") + adminReadReceipt(conversation, messages);
|
||||||
$("#messages").scrollTop = $("#messages").scrollHeight;
|
$("#messages").scrollTop = $("#messages").scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMessageBody(message) {
|
||||||
|
const original = message.body ? `<p>${escapeHtml(message.body)}</p>` : "";
|
||||||
|
if (!message.translatedBody) return original;
|
||||||
|
const label = message.senderType === "visitor"
|
||||||
|
? `Preklad do ${escapeHtml(message.translatedLanguage || "")}`
|
||||||
|
: `Odeslano zakaznikovi ${escapeHtml(message.translatedLanguage || "")}`;
|
||||||
|
return `
|
||||||
|
${original}
|
||||||
|
<div class="translation">
|
||||||
|
<span>${label}</span>
|
||||||
|
<p>${escapeHtml(message.translatedBody)}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async function renameVisitor(visitorToken) {
|
async function renameVisitor(visitorToken) {
|
||||||
if (!state.active?.conversation || !visitorToken) return;
|
if (!state.active?.conversation || !visitorToken) return;
|
||||||
const currentName = conversationVisitorName(state.active.conversation);
|
const currentName = conversationVisitorName(state.active.conversation);
|
||||||
|
|||||||
+2
-1
@@ -356,7 +356,8 @@
|
|||||||
messages.innerHTML = "";
|
messages.innerHTML = "";
|
||||||
let lastAdminName = null;
|
let lastAdminName = null;
|
||||||
for (const message of data.messages) {
|
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";
|
if (message.senderType === "admin") lastAdminName = message.senderName || "Operator";
|
||||||
else lastAdminName = null;
|
else lastAdminName = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || `http://localhost:${PORT}`).replace(/\/$/, "");
|
||||||
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_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_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;
|
||||||
@@ -141,6 +143,7 @@ function initDb() {
|
|||||||
timezone TEXT,
|
timezone TEXT,
|
||||||
ip TEXT,
|
ip TEXT,
|
||||||
country_code TEXT,
|
country_code TEXT,
|
||||||
|
customer_language TEXT,
|
||||||
vpn_risk INTEGER NOT NULL DEFAULT 0,
|
vpn_risk INTEGER NOT NULL DEFAULT 0,
|
||||||
visitor_seen_at TEXT,
|
visitor_seen_at TEXT,
|
||||||
last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -154,6 +157,10 @@ function initDb() {
|
|||||||
sender_type TEXT NOT NULL,
|
sender_type TEXT NOT NULL,
|
||||||
sender_name TEXT,
|
sender_name TEXT,
|
||||||
client_message_id TEXT,
|
client_message_id TEXT,
|
||||||
|
original_language TEXT,
|
||||||
|
translated_body TEXT,
|
||||||
|
translated_language TEXT,
|
||||||
|
translation_status TEXT,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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();
|
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", "visitor_seen_at", "TEXT");
|
||||||
|
ensureColumn("conversations", "customer_language", "TEXT");
|
||||||
ensureColumn("messages", "client_message_id", "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");
|
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,
|
badgeOnAdminReply: true,
|
||||||
labelOnAdminReply: true,
|
labelOnAdminReply: true,
|
||||||
wiggleOnAdminReply: 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);
|
const messageId = insertMessage(conversationId, "visitor", info.name || "Visitor", String(body.message || "").trim(), clientMessageId);
|
||||||
saveAttachments(conversationId, messageId, body.attachments || []);
|
saveAttachments(conversationId, messageId, body.attachments || []);
|
||||||
return conversationId;
|
return { conversationId, messageId };
|
||||||
});
|
});
|
||||||
const insert = insertConversation();
|
const insert = insertConversation();
|
||||||
|
await translateStoredMessage(insert.conversationId, insert.messageId, "visitor", body);
|
||||||
|
|
||||||
const conversation = findConversation(publicId);
|
const conversation = findConversation(publicId);
|
||||||
publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) });
|
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);
|
const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message || "").trim(), clientMessageId);
|
||||||
saveAttachments(conversation.id, messageId, body.attachments || []);
|
saveAttachments(conversation.id, messageId, body.attachments || []);
|
||||||
updateVisitorContext(conversation.id, body);
|
updateVisitorContext(conversation.id, body);
|
||||||
|
await translateStoredMessage(conversation.id, messageId, "visitor", body);
|
||||||
markConversationUnread(conversation.id);
|
markConversationUnread(conversation.id);
|
||||||
bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status);
|
bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status);
|
||||||
const updated = findConversation(conversation.public_id);
|
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" });
|
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());
|
const messageId = insertMessage(conversation.id, "admin", req.adminUser.displayName || req.adminUser.username, String(body.message || "").trim());
|
||||||
saveAttachments(conversation.id, messageId, body.attachments || []);
|
saveAttachments(conversation.id, messageId, body.attachments || []);
|
||||||
|
await translateStoredMessage(conversation.id, messageId, "admin", body);
|
||||||
bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status);
|
bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status);
|
||||||
markConversationSeen(conversation.id);
|
markConversationSeen(conversation.id);
|
||||||
const updated = findConversation(conversation.public_id);
|
const updated = findConversation(conversation.public_id);
|
||||||
@@ -680,9 +700,9 @@ function publish(key, event) {
|
|||||||
|
|
||||||
function insertMessage(conversationId, senderType, senderName, body, clientMessageId = null) {
|
function insertMessage(conversationId, senderType, senderName, body, clientMessageId = null) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
INSERT INTO messages (conversation_id, sender_type, sender_name, client_message_id, body)
|
INSERT INTO messages (conversation_id, sender_type, sender_name, client_message_id, body, translation_status)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(conversationId, senderType, senderName, clientMessageId, body).lastInsertRowid;
|
`).run(conversationId, senderType, senderName, clientMessageId, body, body ? "pending" : "skipped").lastInsertRowid;
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageExists(conversationId, clientMessageId) {
|
function messageExists(conversationId, clientMessageId) {
|
||||||
@@ -707,6 +727,124 @@ function normalizeClientMessageId(value) {
|
|||||||
return /^[a-zA-Z0-9_-]{8,80}$/.test(text) ? text : null;
|
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) {
|
function saveAttachments(conversationId, messageId, attachments) {
|
||||||
for (const attachment of attachments.slice(0, 3)) {
|
for (const attachment of attachments.slice(0, 3)) {
|
||||||
if (!attachment.dataBase64 || !attachment.name) continue;
|
if (!attachment.dataBase64 || !attachment.name) continue;
|
||||||
@@ -827,6 +965,10 @@ function conversationDetails(conversation) {
|
|||||||
senderType: message.sender_type,
|
senderType: message.sender_type,
|
||||||
senderName: message.sender_name,
|
senderName: message.sender_name,
|
||||||
body: message.body,
|
body: message.body,
|
||||||
|
originalLanguage: message.original_language,
|
||||||
|
translatedBody: message.translated_body,
|
||||||
|
translatedLanguage: message.translated_language,
|
||||||
|
translationStatus: message.translation_status,
|
||||||
createdAt: message.created_at,
|
createdAt: message.created_at,
|
||||||
attachments: attachments
|
attachments: attachments
|
||||||
.filter((item) => item.message_id === message.id)
|
.filter((item) => item.message_id === message.id)
|
||||||
@@ -842,6 +984,7 @@ function formatConversationSummary(row) {
|
|||||||
siteKey: row.site_key,
|
siteKey: row.site_key,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
visitorToken: row.visitor_token,
|
visitorToken: row.visitor_token,
|
||||||
|
customerLanguage: row.customer_language || "",
|
||||||
visitorInitials: visitorInitials(row.visitor_token),
|
visitorInitials: visitorInitials(row.visitor_token),
|
||||||
visitorLabel: row.visitor_display_name || visitorLabel(row.visitor_token),
|
visitorLabel: row.visitor_display_name || visitorLabel(row.visitor_token),
|
||||||
visitorDefaultLabel: visitorLabel(row.visitor_token),
|
visitorDefaultLabel: visitorLabel(row.visitor_token),
|
||||||
@@ -892,6 +1035,7 @@ function formatSite(site) {
|
|||||||
const settings = parseJson(site.settings_json, defaultSettings());
|
const settings = parseJson(site.settings_json, defaultSettings());
|
||||||
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 || {}) };
|
||||||
return {
|
return {
|
||||||
id: site.id,
|
id: site.id,
|
||||||
siteKey: site.site_key,
|
siteKey: site.site_key,
|
||||||
@@ -954,6 +1098,11 @@ function findConversation(publicId) {
|
|||||||
`).get(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) {
|
function markConversationSeen(conversationId) {
|
||||||
db.prepare("UPDATE conversations SET admin_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(conversationId);
|
db.prepare("UPDATE conversations SET admin_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(conversationId);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user