diff --git a/package-lock.json b/package-lock.json index 101fa744d..47436a294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "device-detector-js": "^3.0.3", "exifreader": "^4.12.0", "express": "^4.18.2", + "google-translate-api-browser": "^3.0.1", "gpt3-tokenizer": "^1.1.5", "ip-matching": "^2.1.2", "ipaddr.js": "^2.0.1", @@ -1525,6 +1526,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-translate-api-browser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/google-translate-api-browser/-/google-translate-api-browser-3.0.1.tgz", + "integrity": "sha512-KTLodkyGBWMK9IW6QIeJ2zCuju4Z0CLpbkADKo+yLhbSTD4l+CXXpQ/xaynGVAzeBezzJG6qn8MLeqOq3SmW0A==" + }, "node_modules/gpt3-tokenizer": { "version": "1.1.5", "license": "MIT", diff --git a/package.json b/package.json index 98dd786ed..d30048eda 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "device-detector-js": "^3.0.3", "exifreader": "^4.12.0", "express": "^4.18.2", + "google-translate-api-browser": "^3.0.1", "gpt3-tokenizer": "^1.1.5", "ip-matching": "^2.1.2", "ipaddr.js": "^2.0.1", diff --git a/public/index.html b/public/index.html index 0c93297e7..4ab0b1a28 100644 --- a/public/index.html +++ b/public/index.html @@ -2528,6 +2528,7 @@ ${characterName}
+
diff --git a/public/script.js b/public/script.js index 5c3c6249d..88037633e 100644 --- a/public/script.js +++ b/public/script.js @@ -1135,6 +1135,10 @@ function addCopyToCodeBlocks(messageElement) { function addOneMessage(mes, { type = "normal", insertAfter = null, scroll = true } = {}) { var messageText = mes["mes"]; + if (mes?.extra?.display_text) { + messageText = mes.extra.display_text; + } + if (mes.name === name1) { var characterName = name1; //set to user's name by default } else { var characterName = mes.name } @@ -4803,6 +4807,16 @@ function swipe_left() { // when we swipe left..but no generation. this_mes_div.css('height', this_mes_div_height); const this_mes_block_height = this_mes_block[0].scrollHeight; chat[chat.length - 1]['mes'] = chat[chat.length - 1]['swipes'][chat[chat.length - 1]['swipe_id']]; + if (chat[chat.length - 1].extra) { + // if message has memory attached - remove it to allow regen + if (chat[chat.length - 1].extra.memory) { + delete chat[chat.length - 1].extra.memory; + } + // ditto for display text + if (chat[chat.length - 1].extra.display_text) { + delete chat[chat.length - 1].extra.display_text; + } + } $(this).parent().children('.mes_block').transition({ x: swipe_range, duration: swipe_duration, @@ -4901,9 +4915,15 @@ const swipe_right = () => { chat[chat.length - 1]['swipes'][0] = chat[chat.length - 1]['mes']; //assign swipe array with last message from chat } chat[chat.length - 1]['swipe_id']++; //make new slot in array - // if message has memory attached - remove it to allow regen - if (chat[chat.length - 1].extra && chat[chat.length - 1].extra.memory) { - delete chat[chat.length - 1].extra.memory; + if (chat[chat.length - 1].extra) { + // if message has memory attached - remove it to allow regen + if ( chat[chat.length - 1].extra.memory) { + delete chat[chat.length - 1].extra.memory; + } + // ditto for display text + if (chat[chat.length - 1].extra.display_text) { + delete chat[chat.length - 1].extra.display_text; + } } //console.log(chat[chat.length-1]['swipes']); if (parseInt(chat[chat.length - 1]['swipe_id']) === chat[chat.length - 1]['swipes'].length) { //if swipe id of last message is the same as the length of the 'swipes' array diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index 2ce4d4b4c..a762ff366 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -28,6 +28,7 @@ const extension_settings = { tts: {}, sd: {}, chromadb: {}, + translate: {}, }; let modules = []; @@ -342,4 +343,4 @@ $(document).ready(async function () { $("#extensions_details").on('click', showExtensionsDetails); $(document).on('click', '.disable_extension', onDisableExtensionClick); $(document).on('click', '.enable_extension', onEnableExtensionClick); -}); \ No newline at end of file +}); diff --git a/public/scripts/extensions/translate/index.js b/public/scripts/extensions/translate/index.js new file mode 100644 index 000000000..df0fad60d --- /dev/null +++ b/public/scripts/extensions/translate/index.js @@ -0,0 +1,248 @@ +import { eventSource, event_types, getRequestHeaders, messageFormatting, saveSettingsDebounced } from "../../../script.js"; +import { extension_settings, getContext } from "../../extensions.js"; + +const defaultSettings = { + target_language: 'en', + provider: 'google', + auto: false, +}; + +const languageCodes = { + 'Afrikaans': 'af', + 'Albanian': 'sq', + 'Amharic': 'am', + 'Arabic': 'ar', + 'Armenian': 'hy', + 'Azerbaijani': 'az', + 'Basque': 'eu', + 'Belarusian': 'be', + 'Bengali': 'bn', + 'Bosnian': 'bs', + 'Bulgarian': 'bg', + 'Catalan': 'ca', + 'Cebuano': 'ceb', + 'Chinese (Simplified)': 'zh-CN', + 'Chinese (Traditional)': 'zh-TW', + 'Corsican': 'co', + 'Croatian': 'hr', + 'Czech': 'cs', + 'Danish': 'da', + 'Dutch': 'nl', + 'English': 'en', + 'Esperanto': 'eo', + 'Estonian': 'et', + 'Finnish': 'fi', + 'French': 'fr', + 'Frisian': 'fy', + 'Galician': 'gl', + 'Georgian': 'ka', + 'German': 'de', + 'Greek': 'el', + 'Gujarati': 'gu', + 'Haitian Creole': 'ht', + 'Hausa': 'ha', + 'Hawaiian': 'haw', + 'Hebrew': 'iw', + 'Hindi': 'hi', + 'Hmong': 'hmn', + 'Hungarian': 'hu', + 'Icelandic': 'is', + 'Igbo': 'ig', + 'Indonesian': 'id', + 'Irish': 'ga', + 'Italian': 'it', + 'Japanese': 'ja', + 'Javanese': 'jw', + 'Kannada': 'kn', + 'Kazakh': 'kk', + 'Khmer': 'km', + 'Korean': 'ko', + 'Kurdish': 'ku', + 'Kyrgyz': 'ky', + 'Lao': 'lo', + 'Latin': 'la', + 'Latvian': 'lv', + 'Lithuanian': 'lt', + 'Luxembourgish': 'lb', + 'Macedonian': 'mk', + 'Malagasy': 'mg', + 'Malay': 'ms', + 'Malayalam': 'ml', + 'Maltese': 'mt', + 'Maori': 'mi', + 'Marathi': 'mr', + 'Mongolian': 'mn', + 'Myanmar (Burmese)': 'my', + 'Nepali': 'ne', + 'Norwegian': 'no', + 'Nyanja (Chichewa)': 'ny', + 'Pashto': 'ps', + 'Persian': 'fa', + 'Polish': 'pl', + 'Portuguese (Portugal, Brazil)': 'pt', + 'Punjabi': 'pa', + 'Romanian': 'ro', + 'Russian': 'ru', + 'Samoan': 'sm', + 'Scots Gaelic': 'gd', + 'Serbian': 'sr', + 'Sesotho': 'st', + 'Shona': 'sn', + 'Sindhi': 'sd', + 'Sinhala (Sinhalese)': 'si', + 'Slovak': 'sk', + 'Slovenian': 'sl', + 'Somali': 'so', + 'Spanish': 'es', + 'Sundanese': 'su', + 'Swahili': 'sw', + 'Swedish': 'sv', + 'Tagalog (Filipino)': 'tl', + 'Tajik': 'tg', + 'Tamil': 'ta', + 'Telugu': 'te', + 'Thai': 'th', + 'Turkish': 'tr', + 'Ukrainian': 'uk', + 'Urdu': 'ur', + 'Uzbek': 'uz', + 'Vietnamese': 'vi', + 'Welsh': 'cy', + 'Xhosa': 'xh', + 'Yiddish': 'yi', + 'Yoruba': 'yo', + 'Zulu': 'zu', +}; + +function loadSettings() { + if (Object.keys(extension_settings.translate).length === 0) { + Object.assign(extension_settings.translate, defaultSettings); + } + + $(`#translation_provider option[value="${extension_settings.translate.provider}"]`).attr('selected', true); + $(`#translation_target_language option[value="${extension_settings.translate.target_language}"]`).attr('selected', true); + $('#translation_auto').prop('checked', extension_settings.translate.auto); +} + +async function translateIncomingMessage(messageId) { + const context = getContext(); + const message = context.chat[messageId]; + + if (typeof message.extra !== 'object') { + message.extra = {}; + } + + // New swipe is being generated. Don't translate that + if ($(`#chat .mes[mesid="${messageId}"] .mes_text`).text() == '...') { + return; + } + + const translation = await translate(message.mes); + message.extra.display_text = translation; + + $(`#chat .mes[mesid="${messageId}"] .mes_text`).html(messageFormatting(translation, message.name, message.is_system, message.is_user)); + + context.saveChat(); +} + +async function translateProviderGoogle(text) { + const response = await fetch('/google_translate', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ text: text, lang: extension_settings.translate.target_language }), + }); + + if (response.ok) { + const result = await response.text(); + return result; + } + + throw new Error(response.statusText); +} + +async function translate(text) { + try { + switch (extension_settings.translate.provider) { + case 'google': + return await translateProviderGoogle(text); + default: + console.error('Unknown translation provider', extension_settings.translate.provider); + return text; + } + } catch (error) { + console.log(error); + toastr.error('Failed to translate message'); + } +} + +async function translateOutgoingMessage(messageId) { + alert('translateOutgoingMessage', messageId); +} + +jQuery(() => { + const html = ` +
+
+
+ Chat Translation +
+
+
+ + + + + +
+
+
`; + + $('#extensions_settings').append(html); + for (const [key, value] of Object.entries(languageCodes)) { + $('#translation_target_language').append(``); + } + + loadSettings(); + eventSource.on(event_types.MESSAGE_RECEIVED, async (messageId) => { + if (!extension_settings.translate.auto) { + return; + } + await translateIncomingMessage(messageId); + }); + eventSource.on(event_types.MESSAGE_SWIPED, async (messageId) => { + if (!extension_settings.translate.auto) { + return; + } + + await translateIncomingMessage(messageId); + }); + eventSource.on(event_types.MESSAGE_SENT, async (messageId) => { + if (!extension_settings.translate.auto) { + return; + } + + await translateOutgoingMessage(messageId); + }); + $('#translation_auto').on('input', (event) => { + extension_settings.translate.auto = event.target.checked; + saveSettingsDebounced(); + }); + $('#translation_provider').on('change', (event) => { + extension_settings.translate.provider = event.target.value; + saveSettingsDebounced(); + }); + $('#translation_target_language').on('change', (event) => { + extension_settings.translate.target_language = event.target.value; + saveSettingsDebounced(); + }); + $(document).on('click', '.mes_translate', function () { + const messageId = $(this).closest('.mes').attr('mesid'); + translateIncomingMessage(messageId); + }); + document.body.classList.add('translate'); +}); diff --git a/public/scripts/extensions/translate/manifest.json b/public/scripts/extensions/translate/manifest.json new file mode 100644 index 000000000..401db9627 --- /dev/null +++ b/public/scripts/extensions/translate/manifest.json @@ -0,0 +1,11 @@ +{ + "display_name": "Chat Translation", + "loading_order": 10, + "requires": [], + "optional": [], + "js": "index.js", + "css": "style.css", + "author": "Cohee#1207", + "version": "1.0.0", + "homePage": "https://github.com/Cohee1207/SillyTavern" +} diff --git a/public/scripts/extensions/translate/style.css b/public/scripts/extensions/translate/style.css new file mode 100644 index 000000000..e69de29bb diff --git a/public/style.css b/public/style.css index 599e7caa8..4d8a2e8db 100644 --- a/public/style.css +++ b/public/style.css @@ -233,6 +233,7 @@ table.responsiveTable { text-align: center; } +.mes_translate, .sd_message_gen, .mes_narrate, body.tts .mes[is_user="true"] .mes_narrate, @@ -266,6 +267,7 @@ body.tts .mes[is_system="true"] .mes_narrate { } body.sd .sd_message_gen, +body.translate .mes_translate, body.tts .mes_narrate { display: inline-block; } diff --git a/server.js b/server.js index fb48eb6d4..76a84513f 100644 --- a/server.js +++ b/server.js @@ -2975,6 +2975,39 @@ app.post('/horde_generateimage', jsonParser, async (request, response) => { } }); +app.post('/google_translate', jsonParser, async (request, response) => { + const { generateRequestUrl, normaliseResponse } = require('google-translate-api-browser'); + const https = require('https'); + + const text = request.body.text; + const lang = request.body.lang; + + if (!text || !lang) { + return response.sendStatus(400); + } + + console.log('Input text: ' + text); + + const url = generateRequestUrl(text, { to: lang }); + + https.get(url, (resp) => { + let data = ''; + + resp.on('data', (chunk) => { + data += chunk; + }); + + resp.on('end', () => { + const result = normaliseResponse(JSON.parse(data)); + console.log('Translated text: ' + result.text); + return response.send(result.text); + }); + }).on("error", (err) => { + console.log("Translation error: " + err.message); + return response.sendStatus(500); + }); +}); + function writeSecret(key, value) { if (!fs.existsSync(SECRETS_FILE)) { const emptyFile = JSON.stringify({});