diff --git a/app.py b/app.py index dd2d4e7..9c9808d 100644 --- a/app.py +++ b/app.py @@ -3,7 +3,7 @@ import threading # TEST -from flask import Flask, render_template, request +from flask import Flask, render_template, request, jsonify from flask_socketio import SocketIO, emit from partygpt import AiGuest @@ -14,6 +14,7 @@ PATH_SETTINGS = 'settings.yml' APPLICATION_SETTINGS = read_yaml(path=PATH_SETTINGS)['application'] PATH_FOLDER_CONVERSATION_RECORDS = APPLICATION_SETTINGS['conversation_records_folder'] +PATH_FOLDER_CONVERSATION_RECORDS_NAME = APPLICATION_SETTINGS['records_folder_name'] REFRESH_TIMER_AI_GOODBYE = APPLICATION_SETTINGS['refresh_conversation_after_ai_says_goodbye'] app = Flask(__name__) @@ -27,7 +28,7 @@ log_console_handler = logging.StreamHandler() log_console_handler.setLevel(logging.INFO) log_console_handler.setFormatter(log_formatter) -log_file_handler = logging.FileHandler('debug.log') +log_file_handler = logging.FileHandler('debug.log', encoding='utf-8') log_file_handler.setLevel(logging.DEBUG) log_file_handler.setFormatter(log_formatter) root_logger = logging.getLogger() @@ -46,6 +47,7 @@ def log_connect(data): @app.route('/') def index(): + ai_guest.set_conversation_record_folder_path(PATH_FOLDER_CONVERSATION_RECORDS_NAME) return render_template('index.html') @app.route('/process-input', methods=['POST']) @@ -74,6 +76,26 @@ def refresh_session(): ai_guest.reset(persona=None) return '' # may not return None or omit return statement +@app.route('/save-records', methods=['GET']) +def save_records(): + ai_guest.save_messages_history() + return '', 200 + +@app.route('/set-records', methods=['GET']) +def set_records(): + flag = request.args.get('flag') + logger.info(f'Chat recording is set to: {flag}') + response_data = {} + if flag == 'true': # (?)flag param can only be interpreted as string + ai_guest.set_conversation_record_folder_path(PATH_FOLDER_CONVERSATION_RECORDS_NAME) + response_data['message'] = 'Chat will be recorded' + else: + ai_guest.set_conversation_record_folder_path('') + response_data['message'] = 'Chat will not be recorded' + + response = jsonify(response_data) + return response, 200, {'Content-Type': 'application/json'} + def trigger_session_refresh(goodbye_msg): logger.info('Session refreshed (per backend).') sockets.emit('instruction', { diff --git a/partygpt/partygpt.py b/partygpt/partygpt.py index 5a5bf1a..f44f4cb 100644 --- a/partygpt/partygpt.py +++ b/partygpt/partygpt.py @@ -41,12 +41,16 @@ def get_accumulated_costs(self, verbose=True): return total_t['sum'] def _clear_messages_history(self) -> None: + self._messages_history = [] + + def save_messages_history(self) -> None: if self._conversation_record_folder_path and self._messages_history: # only record if path defined + logger.info('Chat records is now saving...') try: os.makedirs(self._conversation_record_folder_path, exist_ok=True) file_name = os.path.join(self._conversation_record_folder_path, str(self._current_conversation_start) + '.convrec') - with open(file_name, 'x') as f: + with open(file_name, 'x', encoding='utf-8') as f: for el in self._messages_history: role_ = el['role'] content_ = el['content'] @@ -63,8 +67,7 @@ def _clear_messages_history(self) -> None: logger.info(f'Wrote conversation to {file_name}.') except Exception as e: logger.error(f'Trouble writing conversation history to file: {type(e).__name__}: {e}') - - self._messages_history = [] + def set_functions( self, @@ -87,6 +90,13 @@ def reset(self): self._clear_messages_history() self._current_conversation_start = int(time.time()) + def set_conversation_record_folder_path(self, new_path): + self._conversation_record_folder_path = new_path + if new_path != '': + logger.info(f'Chat will be recorded to {new_path}') + else: + logger.info('Chat will not be recorded') + def communicate( self, messages: list, diff --git a/settings.yml b/settings.yml index 6e3e8df..3509091 100644 --- a/settings.yml +++ b/settings.yml @@ -1,7 +1,8 @@ application: refresh_conversation_after_ai_says_goodbye: 30 # seconds - conversation_records_folder: conversation_records # remove the characters after ":" to deactivate conversation recording - + records_folder_name: &name_anchor conversation_records + conversation_records_folder: *name_anchor # remove the characters after ":" to deactivate conversation recording + model: id: gpt-3.5-turbo @@ -36,6 +37,8 @@ guest: special_skill: # you can ___. - tell funny jokes - provide facts about science and nature in an understandable way + - talk like Barney Stinson + - talk like Winnie the Pooh - talk like a pirate - create incredible drinks (you actually named the party's signature drink "Rum Royale Bliss") - start playing a quiz on basic knowledge with the guest diff --git a/static/css/styles.css b/static/css/styles.css index fe44ff3..128a5bb 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -37,11 +37,62 @@ h1 { margin-bottom: 10px; } +.mode-container { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + text-align: center; +} + +.button-container { + display: flex; + justify-content: space-between; +} + +.button-container button { + background-color: #4caf50; + color: #fff; + padding: 10px 20px; + font-size: 16px; + border: none; + border-radius: 5px; + cursor: pointer; +} + +#send-button:hover { + background-color: #45a049; +} + +#close-button { + background-color: #f44336; +} + +#close-button:hover { + background-color: #d32f2f; +} + +#mode-toggle-button { + background-color: #2fd2f3; +} + + +#chat-info { + display: flex; + justify-content: space-between; + margin-bottom: 20px; +} + +#recording-info { + margin-right: 10px; +} + #user-input { flex: 1; padding: 10px; font-size: 16px; border: none; + width: 100%; border-radius: 5px; } @@ -72,30 +123,28 @@ h1 { margin-bottom: 10px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); white-space: pre-wrap; -} /* TODO: class for common properties of user-input and ai-message */ - -#send-button, -#close-button { - background-color: #4caf50; - color: #fff; - padding: 10px 20px; - font-size: 16px; - border: none; - border-radius: 5px; - cursor: pointer; } -#send-button:hover { - background-color: #45a049; -} +/* TODO: class for common properties of user-input and ai-message */ -#close-button { - position: absolute; - bottom: 20px; - right: 20px; - background-color: #f44336; -} -#close-button:hover { - background-color: #d32f2f; + +.icon-container { + display: inline-block; + text-decoration: none; + padding: 10px; + border-radius: 50%; + /* Makes it a circle */ + background-color: #007bff; + color: #fff; + /* Text color */ + transition: background-color 0.3s ease; + /* Smooth background color transition */ + margin-left: 30px; } + +#audio-input-icon { + width: 24px; + height: 24px; + vertical-align: middle; +} \ No newline at end of file diff --git a/static/images/mic-icon.png b/static/images/mic-icon.png new file mode 100644 index 0000000..8a07e2e Binary files /dev/null and b/static/images/mic-icon.png differ diff --git a/static/js/audio.mjs b/static/js/audio.mjs new file mode 100644 index 0000000..1527b65 --- /dev/null +++ b/static/js/audio.mjs @@ -0,0 +1,127 @@ +import { handleUserInput } from './script.mjs'; +import { franc } from 'https://esm.sh/franc@6?bundle'; //https://github.com/wooorm/franc +import { iso6393To1 } from './iso6393-to-bcp47.mjs'; + +//https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API#speech_recognition +var recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)(); +var isSpeakMode = false; +var isNowSpeaking = false; + + +export function textToSpeech(message, isByClicking) { + //Only in speak mode the new message will be spoken automatically + //every time when a new reply is received, this function will be called from script.mjs. + //But script.mjs doesn't hold the information whether it is speak mode now, so this condition will be checked here firstly + + if (isNowSpeaking && isByClicking) { + console.log('User stopped the speaking'); + speechSynthesis.cancel(); + return; + } + + if ((isSpeakMode || isByClicking)) { + console.log('message to speak:', message); + //https://developer.mozilla.org/en-US/docs/Web/API/SpeechSynthesisUtterance + var utterance = new SpeechSynthesisUtterance(); + + utterance.onstart = function (event) { + console.log('Speech started'); + isNowSpeaking = true; + }; + + utterance.onend = function (event) { + console.log('Speech ended'); + isNowSpeaking = false; + }; + + utterance.onerror = function (event) { + //In tested chrome browser the speaking sometimes abrupts without calling onerror or onend + //but with Edge it is ok. + console.error('Speech synthesis error:', event.error); + isNowSpeaking = false; + }; + + var detectedLanguage = franc(message); + console.log("Detected language code by franc:", detectedLanguage); + utterance.lang = mapISO6393toISO6391(detectedLanguage) || 'en-US'; + //for code "zh", chrome uses mandarin, and edge uses cantonese :O maybe use BCP47 language code will be exacter + console.log("Language code in ISO639-1:", utterance.lang); + utterance.text = message; + utterance.rate = 1.3; + speechSynthesis.speak(utterance); + } +} + +export function setUpSpeechRecognition() { + recognition.continuous = true; // Enable continuous recognition + + recognition.onerror = (event) => { + console.error('Speech recognition error:', event.error); + }; + + recognition.onstart = () => { + console.log('Speech recognition started.'); + }; + + recognition.addEventListener('result', (event) => { + const transcript = event.results[0][0].transcript; + console.log('Recognized text:', transcript); + recognition.stop(); + let message = { value: transcript }; + handleUserInput(message); + }); +}; + + +function toggleMode(btn) { + console.log("Mode toggled. IsSpeakMode:", isSpeakMode); + + var speakModeDiv = document.getElementById('speak-mode-container'); + var writeModeDiv = document.getElementById('write-mode-container'); + + if (isSpeakMode) { + btn.textContent = 'Write Mode'; + speakModeDiv.style.display = 'block'; + writeModeDiv.style.display = 'none'; + + var LanguageDropdown = document.getElementById("language-dropdown"); + LanguageDropdown.addEventListener('change', () => { + //console.log('change language'); + setSpeechRecognitionLanguage(LanguageDropdown.value); + }); + + var audioInputIcon = document.getElementById("audio-input-icon"); + audioInputIcon.addEventListener('click', () => { + recognition.start(); + }); + + setUpSpeechRecognition(); + setSpeechRecognitionLanguage(LanguageDropdown.value); + } else { + btn.textContent = 'Speak Mode'; + speakModeDiv.style.display = 'none'; + writeModeDiv.style.display = 'block'; + var userInput = document.getElementById("user-input"); + userInput.focus(); + } +} + +function setSpeechRecognitionLanguage(lang) { + console.log('set language to: ', lang); + recognition.lang = lang; +}; + + +function mapISO6393toISO6391(iso6393Code) { + return iso6393To1[iso6393Code]; +} + +document.addEventListener("DOMContentLoaded", function () { + + var modeToggleButton = document.getElementById("mode-toggle-button"); + + modeToggleButton.addEventListener("click", function () { + isSpeakMode = !isSpeakMode; + toggleMode(modeToggleButton); + }); +}); \ No newline at end of file diff --git a/static/js/iso6393-to-bcp47.mjs b/static/js/iso6393-to-bcp47.mjs new file mode 100644 index 0000000..5edfd9e --- /dev/null +++ b/static/js/iso6393-to-bcp47.mjs @@ -0,0 +1,419 @@ +/** + * Map of ISO 639-3 codes to ISO 639-1 codes. + * + * @type {Record} + */ +export const iso6393To1 = { + "aae": "sq", + "aao": "ar", + "aar": "aa", + "aat": "sq", + "abh": "ar", + "abk": "ab", + "abv": "ar", + "acm": "ar", + "acq": "ar", + "acw": "ar", + "acx": "ar", + "acy": "ar", + "adf": "ar", + "aeb": "ar", + "aec": "ar", + "afb": "ar", + "afr": "af", + "ajp": "ar", + "aka": "ak", + "aln": "sq", + "als": "sq", + "amh": "am", + "apc": "ar", + "apd": "ar", + "ara": "ar", + "arb": "ar", + "arg": "an", + "arq": "ar", + "ars": "ar", + "ary": "ar", + "arz": "ar", + "asm": "as", + "auz": "ar", + "ava": "av", + "ave": "ae", + "avl": "ar", + "ayc": "ar", + "ayh": "ar", + "ayl": "ar", + "aym": "ay", + "ayn": "ar", + "ayp": "ar", + "ayr": "ay", + "azb": "az", + "aze": "az", + "azj": "az", + "bak": "ba", + "bam": "bm", + "bbz": "ar", + "bel": "be", + "ben": "bn", + "bhr": "mg", + "bis": "bi", + "bjn": "ms", + "bmm": "mg", + "bod": "bo", + "bos": "sh", + "bre": "br", + "btj": "ms", + "bul": "bg", + "bve": "ms", + "bvu": "ms", + "bzc": "mg", + "cat": "ca", + "cdo": "zh", + "ces": "cs", + "cha": "ch", + "che": "ce", + "chu": "cu", + "chv": "cv", + "cjy": "zh", + "ckb": "ku", + "cmn": "zh", + "coa": "ms", + "cor": "kw", + "cos": "co", + "cpx": "zh", + "cre": "cr", + "crj": "cr", + "crk": "cr", + "crl": "cr", + "crm": "cr", + "csw": "cr", + "cwd": "cr", + "cym": "cy", + "czh": "zh", + "czo": "zh", + "dan": "da", + "deu": "de", + "div": "dv", + "dty": "ne", + "dup": "ms", + "dzo": "dz", + "ekk": "et", + "ell": "el", + "eng": "en", + "epo": "eo", + "esi": "ik", + "esk": "ik", + "est": "et", + "eus": "eu", + "ewe": "ee", + "fao": "fo", + "fas": "fa", + "fat": "ak", + "ffm": "ff", + "fij": "fj", + "fin": "fi", + "fra": "fr", + "fry": "fy", + "fub": "ff", + "fuc": "ff", + "fue": "ff", + "fuf": "ff", + "fuh": "ff", + "fui": "ff", + "ful": "ff", + "fuq": "ff", + "fuv": "ff", + "gan": "zh", + "gax": "om", + "gaz": "om", + "gla": "gd", + "gle": "ga", + "glg": "gl", + "glv": "gv", + "gnw": "gn", + "grn": "gn", + "gug": "gn", + "gui": "gn", + "guj": "gu", + "gun": "gn", + "hae": "om", + "hak": "zh", + "hat": "ht", + "hau": "ha", + "hbs": "sh", + "heb": "he", + "her": "hz", + "hin": "hi", + "hji": "ms", + "hmo": "ho", + "hrv": "sh", + "hsn": "zh", + "hun": "hu", + "hye": "hy", + "ibo": "ig", + "ido": "io", + "iii": "ii", + "ike": "iu", + "ikt": "iu", + "iku": "iu", + "ile": "ie", + "ina": "ia", + "ind": "ms", + "ipk": "ik", + "isl": "is", + "ita": "it", + "jak": "ms", + "jav": "jv", + "jax": "ms", + "jpn": "ja", + "kal": "kl", + "kan": "kn", + "kas": "ks", + "kat": "ka", + "kau": "kr", + "kaz": "kk", + "kby": "kr", + "khk": "mn", + "khm": "km", + "kik": "ki", + "kin": "rw", + "kir": "ky", + "kmr": "ku", + "knc": "kr", + "kng": "kg", + "koi": "kv", + "kom": "kv", + "kon": "kg", + "kor": "ko", + "kpv": "kv", + "krt": "kr", + "kua": "kj", + "kur": "ku", + "kvb": "ms", + "kvr": "ms", + "kwy": "kg", + "kxd": "ms", + "lao": "lo", + "lat": "la", + "lav": "lv", + "lce": "ms", + "lcf": "ms", + "ldi": "kg", + "lim": "li", + "lin": "ln", + "lit": "lt", + "liw": "ms", + "ltg": "lv", + "ltz": "lb", + "lub": "lu", + "lug": "lg", + "lvs": "lv", + "lzh": "zh", + "mah": "mh", + "mal": "ml", + "mar": "mr", + "max": "ms", + "meo": "ms", + "mfa": "ms", + "mfb": "ms", + "min": "ms", + "mkd": "mk", + "mlg": "mg", + "mlt": "mt", + "mnp": "zh", + "mon": "mn", + "mqg": "ms", + "mri": "mi", + "msa": "ms", + "msh": "mg", + "msi": "ms", + "mui": "ms", + "mvf": "mn", + "mya": "my", + "nan": "zh", + "nau": "na", + "nav": "nv", + "nbl": "nr", + "nde": "nd", + "ndo": "ng", + "nep": "ne", + "nhd": "gn", + "nld": "nl", + "nno": "no", + "nob": "no", + "nor": "no", + "npi": "ne", + "nya": "ny", + "oci": "oc", + "ojb": "oj", + "ojc": "oj", + "ojg": "oj", + "oji": "oj", + "ojs": "oj", + "ojw": "oj", + "orc": "om", + "ori": "or", + "orm": "om", + "orn": "ms", + "ors": "ms", + "ory": "or", + "oss": "os", + "otw": "oj", + "pan": "pa", + "pbt": "ps", + "pbu": "ps", + "pel": "ms", + "pes": "fa", + "pga": "ar", + "pli": "pi", + "plt": "mg", + "pol": "pl", + "por": "pt", + "prs": "fa", + "pse": "ms", + "pst": "ps", + "pus": "ps", + "qub": "qu", + "qud": "qu", + "que": "qu", + "quf": "qu", + "qug": "qu", + "quh": "qu", + "quk": "qu", + "qul": "qu", + "qup": "qu", + "qur": "qu", + "qus": "qu", + "quw": "qu", + "qux": "qu", + "quy": "qu", + "quz": "qu", + "qva": "qu", + "qvc": "qu", + "qve": "qu", + "qvh": "qu", + "qvi": "qu", + "qvj": "qu", + "qvl": "qu", + "qvm": "qu", + "qvn": "qu", + "qvo": "qu", + "qvp": "qu", + "qvs": "qu", + "qvw": "qu", + "qvz": "qu", + "qwa": "qu", + "qwc": "qu", + "qwh": "qu", + "qws": "qu", + "qxa": "qu", + "qxc": "qu", + "qxh": "qu", + "qxl": "qu", + "qxn": "qu", + "qxo": "qu", + "qxp": "qu", + "qxr": "qu", + "qxt": "qu", + "qxu": "qu", + "qxw": "qu", + "roh": "rm", + "ron": "ro", + "run": "rn", + "rus": "ru", + "sag": "sg", + "san": "sa", + "sdc": "sc", + "sdh": "ku", + "sdn": "sc", + "shu": "ar", + "sin": "si", + "skg": "mg", + "slk": "sk", + "slv": "sl", + "sme": "se", + "smo": "sm", + "sna": "sn", + "snd": "sd", + "som": "so", + "sot": "st", + "spa": "es", + "spv": "or", + "sqi": "sq", + "src": "sc", + "srd": "sc", + "sro": "sc", + "srp": "sh", + "ssh": "ar", + "ssw": "ss", + "sun": "su", + "swa": "sw", + "swc": "sw", + "swe": "sv", + "swh": "sw", + "tah": "ty", + "tam": "ta", + "tat": "tt", + "tdx": "mg", + "tel": "te", + "tgk": "tg", + "tgl": "tl", + "tha": "th", + "tir": "ti", + "tkg": "mg", + "tmw": "ms", + "ton": "to", + "tsn": "tn", + "tso": "ts", + "tuk": "tk", + "tur": "tr", + "twi": "ak", + "txy": "mg", + "uig": "ug", + "ukr": "uk", + "urd": "ur", + "urk": "ms", + "uzb": "uz", + "uzn": "uz", + "uzs": "uz", + "ven": "ve", + "vie": "vi", + "vkk": "ms", + "vkt": "ms", + "vol": "vo", + "vro": "et", + "wln": "wa", + "wol": "wo", + "wuu": "zh", + "xho": "xh", + "xmm": "ms", + "xmv": "mg", + "xmw": "mg", + "ydd": "yi", + "yid": "yi", + "yih": "yi", + "yor": "yo", + "yue": "zh", + "zch": "za", + "zeh": "za", + "zgb": "za", + "zgm": "za", + "zgn": "za", + "zha": "za", + "zhd": "za", + "zhn": "za", + "zho": "zh", + "zlj": "za", + "zlm": "ms", + "zln": "za", + "zlq": "za", + "zmi": "ms", + "zqe": "za", + "zsm": "ms", + "zul": "zu", + "zyb": "za", + "zyg": "za", + "zyj": "za", + "zyn": "za", + "zzj": "za" +} \ No newline at end of file diff --git a/static/js/script.js b/static/js/script.js deleted file mode 100644 index 54bfefd..0000000 --- a/static/js/script.js +++ /dev/null @@ -1,155 +0,0 @@ -document.addEventListener("DOMContentLoaded", function() { - - // ---------------- USER INTERACTION ---------------- - var conversation = document.getElementById("conversation"); - var userInput = document.getElementById("user-input"); - var sendButton = document.getElementById("send-button"); - var closeButton = document.getElementById("close-button"); - - - function setFocusOnInput() { - userInput.focus(); - } - - setFocusOnInput(); - - - function addMessage(message, sender) { - var messageElement = document.createElement("div"); - messageElement.classList.add("message"); - if (sender === "user") { - messageElement.classList.add("user-input"); - messageElement.textContent = "You: " + message; - } else if (sender === "assistant") { - messageElement.classList.add("ai-message"); - messageElement.textContent = "AI: " + message; // BACKUP: render newlines: .replace(/(?:\r\n|\r|\n)/g, '
'); - } - else { - console.error('Unknown message sender:', sender); - return; - } - conversation.appendChild(messageElement); - conversation.scrollTop = conversation.scrollHeight; - setFocusOnInput(); - } - - - function handleUserInput() { - var message = userInput.value; - if (message.trim() !== "") { - addMessage(message, "user"); - userInput.value = ""; - // TODO: sanitize input; avoid code injection - // Send the message to your backend using AJAX or WebSocket - fetch('/process-input', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ message }), - }) - // and handle the response from the assistant - .then((response) => response.json()) - .then((data) => { - const reply = data.reply; - if (reply === '' || reply === null) { - console.log('Received null response from backend.'); - } - else { - addMessage(reply, 'assistant'); - } - }) - .catch((error) => { - console.error('Error:', error); - }); - } - } - - - sendButton.addEventListener("click", function() { - handleUserInput(); - }); - - - document.addEventListener("keydown", function(event) { - if (event.shiftKey && event.key === "Enter") { - event.preventDefault(); // Prevent form submission - var currentCursorPosition = userInput.selectionStart; // Get current cursor position - var inputValue = userInput.value; - var newValue = inputValue.substring(0, currentCursorPosition) + "\r\n" + inputValue.substring(userInput.selectionEnd); - userInput.value = newValue; - userInput.setSelectionRange(currentCursorPosition + 1, currentCursorPosition + 1); // Set cursor position after the inserted newline - } - else if (event.key === "Enter") { - event.preventDefault(); - handleUserInput(); - } - }); - - - function lockInputField() { - userInput.disabled = true; - } - - - function unlockInputField() { - userInput.disabled = false; - } - - - function close_session(){ - conversation.innerHTML = ""; - fetch('/close-session', { - method: 'GET' - }); - console.log('Session refreshed'); - unlockInputField(); - setFocusOnInput(); - } - - - closeButton.addEventListener("click", function() { - close_session(); - }); - - - - // ---------------- IDLE REFRESH ---------------- - const idleTimeoutDuration = 3 * 60 * 1000; - let idleTimer; - - function startIdleTimer() { - clearTimeout(idleTimer); - idleTimer = setTimeout(close_session, idleTimeoutDuration); - } - - const inputElements = document.querySelectorAll('input, textarea'); - inputElements.forEach(function (input) { - input.addEventListener('input', startIdleTimer); - }); - - // startIdleTimer(); // TODO: remove? - - - - - // ---------------- INSTRUCTIONS FROM BACKEND ---------------- - var socket = io(); - socket.on('connect', function() { - socket.emit('connection', {state: 'success'}); - }); - - socket.on('instruction', function(instruction) { - type = instruction.type - if (type == 'refresh_session_timer') { - addMessage(instruction.goodbye_msg, 'assistant'); - lockInputField(); - setTimeout(close_session, instruction.timer * 1000); // TODO: clear timer if second call here - } - else { - console.error('Unknown instruction:', type); - } - }); - - - }); diff --git a/static/js/script.mjs b/static/js/script.mjs new file mode 100644 index 0000000..df0df77 --- /dev/null +++ b/static/js/script.mjs @@ -0,0 +1,244 @@ +import { textToSpeech } from './audio.mjs'; + +var speechSynthesis = 'speechSynthesis' in window; + +export function handleUserInput(userInput) { + var message = userInput.value; + if (message.trim() !== "") { + addMessage(message, "user"); + userInput.value = ""; + // TODO: sanitize input; avoid code injection + // Send the message to your backend using AJAX or WebSocket + fetch('/process-input', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message }), + }) + // and handle the response from the assistant + .then((response) => response.json()) + .then((data) => { + const reply = data.reply; + if (reply === '' || reply === null) { + console.log('Received null response from backend.'); + } + else { + addMessage(reply, 'assistant'); + } + }) + .catch((error) => { + console.error('Error:', error); + }); + } +} + +function addMessage(message, sender) { + var messageElement = document.createElement("div"); + messageElement.classList.add("message"); + if (sender === "user") { + messageElement.classList.add("user-input"); + messageElement.textContent = "You: " + message; + } else if (sender === "assistant") { + messageElement.classList.add("ai-message"); + messageElement.textContent = "AI: " + message; // BACKUP: render newlines: .replace(/(?:\r\n|\r|\n)/g, '
'); + if(speechSynthesis){ + textToSpeech(message); + messageElement.addEventListener("click", function () { + textToSpeech(message, true); + }); + messageElement.addEventListener("contextmenu", function () { //on right click + stopSpeak(); + }); + } + } + else { + console.error('Unknown message sender:', sender); + return; + } + + conversation.appendChild(messageElement); //Why conversation can be accessed from here magically? 0_0 somewhere saved this as global? + conversation.scrollTop = conversation.scrollHeight; +} + +document.addEventListener("DOMContentLoaded", function () { + + // ---------------- USER INTERACTION ---------------- + var conversation = document.getElementById("conversation"); + var userInput = document.getElementById("user-input"); + var buttonContainer = document.getElementById("button-container"); + var sendButton = document.getElementById("send-button"); + var closeButton = document.getElementById("close-button"); + var setRecordButton = document.getElementById("set-recording"); + var recordStatus = document.getElementById("recording-status"); + var recordInfo = document.getElementById("recording-info"); + var newSessionHint = document.getElementById("new-session-hint"); + + checkSpeakMode(); + chatStartUIChange(); + + const featureFlags = { + allowRecords: true + }; + + function checkSpeakMode() { + var SpeechRecognition = 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window; + + if (!SpeechRecognition) { + var modeToggleBtn = document.getElementById("mode-toggle-button"); + console.error('Speech recognition is not supported in this browser.'); + changeDivDisplay(modeToggleBtn, 'none'); + } + if (!speechSynthesis) { + console.error('Speech synthesis not supported in this browser.'); + var speakInfo = document.getElementById("speak-info"); + changeDivDisplay(speakInfo, 'none'); + } + }; + + sendButton.addEventListener("click", function () { + handleUserInput(userInput); + setFocusOnInput(); + }); + + + document.addEventListener("keydown", function (event) { + if (event.shiftKey && event.key === "Enter") { + event.preventDefault(); // Prevent form submission + var currentCursorPosition = userInput.selectionStart; // Get current cursor position + var inputValue = userInput.value; + var newValue = inputValue.substring(0, currentCursorPosition) + "\r\n" + inputValue.substring(userInput.selectionEnd); + userInput.value = newValue; + userInput.setSelectionRange(currentCursorPosition + 1, currentCursorPosition + 1); // Set cursor position after the inserted newline + } + else if (event.key === "Enter") { + event.preventDefault(); + handleUserInput(userInput); + setFocusOnInput(); + } + }); + + function chatEndedUIChange() { + changeDivDisableStatus(userInput, true); + changeDivDisplay(buttonContainer, 'none'); + changeDivDisplay(recordInfo, 'none'); + } + + function chatStartUIChange() { + changeDivDisplay(newSessionHint, 'none'); + changeDivDisableStatus(userInput, false); + setFocusOnInput(); + changeDivDisplay(buttonContainer, 'flex'); + changeDivDisplay(recordInfo, 'block'); + } + + function setFocusOnInput() { + userInput.focus(); + } + + function changeDivDisplay(div, display) { + div.style.display = display; + } + + function changeDivDisableStatus(div, disabled) { + div.disabled = disabled; + } + + function setRecord(flag) { + const url = `/set-records?flag=${flag}`; + fetch(url, { + method: 'GET' + }) + .then((response) => { + if (response.status === 200) { + return response.json(); + } else { + console.error('Request failed with status:', response.status); + } + }) + .then((data) => { + recordStatus.textContent = data.message; + setRecordButton.textContent = featureFlags.allowRecords ? 'Disable recording' : 'Enable recording' + }); + } + + function saveRecords() { + fetch('/save-records', { + method: 'GET' + }); + } + + function close_session() { + conversation.innerHTML = ""; + fetch('/close-session', { + method: 'GET' + }); + console.log('Session refreshed'); + refreshNewSession(); + } + + function refreshNewSession() { + featureFlags.allowRecords = true; //enable chat recording by default + chatStartUIChange(); + setRecord(featureFlags.allowRecords); + + } + + closeButton.addEventListener("click", function () { + saveRecords(); + close_session(); + }); + + setRecordButton.addEventListener("click", function () { + featureFlags.allowRecords = !featureFlags.allowRecords; + console.log("chat recording flag is now " + featureFlags.allowRecords); + setRecord(featureFlags.allowRecords); + }) + + window.addEventListener('beforeunload', function (event) { //before browser is closed + if(featureFlags.allowRecords){ + saveRecords(); + } + }); + + // ---------------- IDLE REFRESH ---------------- + const idleTimeoutDuration = 3 * 60 * 1000; + let idleTimer; + + function startIdleTimer() { + clearTimeout(idleTimer); + idleTimer = setTimeout(function () { + saveRecords(); + close_session(); + }, idleTimeoutDuration); + } + + // Listen for clicks and input on the document + document.addEventListener('click', startIdleTimer); + document.addEventListener('input', startIdleTimer); + + // startIdleTimer(); // TODO: remove? + + + // ---------------- INSTRUCTIONS FROM BACKEND ---------------- + var socket = io(); + socket.on('connect', function () { + socket.emit('connection', { state: 'success' }); + }); + + socket.on('instruction', function (instruction) { + let type = instruction.type; + if (type == 'refresh_session_timer') { + addMessage(instruction.goodbye_msg, 'assistant'); + chatEndedUIChange(); + saveRecords(); + setTimeout(close_session, instruction.timer * 1000); // TODO: clear timer if second call here + changeDivDisplay(newSessionHint, 'block'); + } + else { + console.error('Unknown instruction:', type); + } + }); + + +}); diff --git a/templates/index.html b/templates/index.html index 4602792..63e818f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,26 +1,72 @@ + PartyGPT - + +

PartyGPT

+
+
+

Click on the response of our special guest, it will speak to you! +
Click again to stop him talking 🤐 +

+
+
+

Chat will be recorded.

+ +
+
+
- + +
+ +
-
+ +
+
+ +
+ +
+
- + + + - + + \ No newline at end of file