diff --git a/public/index.html b/public/index.html index 2d436c047..9b67dac38 100644 --- a/public/index.html +++ b/public/index.html @@ -653,10 +653,17 @@
Sent with every prompt to modify bot responses.
-
@@ -2123,6 +2130,9 @@ + +
diff --git a/public/script.js b/public/script.js index 1508d7db8..ae9e5a681 100644 --- a/public/script.js +++ b/public/script.js @@ -148,6 +148,7 @@ import { } from "./scripts/secrets.js"; import { EventEmitter } from './scripts/eventemitter.js'; import { context_settings, loadContextTemplatesFromSettings } from "./scripts/context-template.js"; +import { dinkusExtension } from "./scripts/showdown-dinkus.js"; //exporting functions and vars for mods export { @@ -536,6 +537,14 @@ function reloadMarkdownProcessor(render_formulas = false) { }); } + // Inject the dinkus extension after creating the converter + // Maybe move this into power_user init? + setTimeout(() => { + if (power_user) { + converter.addExtension(dinkusExtension(), 'dinkus'); + } + }, 1) + return converter; } @@ -1778,22 +1787,22 @@ class StreamingProcessor { await delay(1); // delay for message to be rendered } - for await (const text of this.generator()) { - if (this.isStopped) { - this.onStopStreaming(); - return; - } + try { + for await (const text of this.generator()) { + if (this.isStopped) { + this.onStopStreaming(); + return; + } - try { this.result = text; this.onProgressStreaming(this.messageId, message_already_generated + text); } - catch (err) { - console.error(err); - this.onErrorStreaming(); - this.isStopped = true; - return; - } + } + catch (err) { + console.error(err); + this.onErrorStreaming(); + this.isStopped = true; + return; } this.isFinished = true; @@ -6548,7 +6557,7 @@ $(document).ready(function () { if (this_chid !== undefined || selected_group) { // Previously system messages we're allowed to be edited /*const message = $(this).closest(".mes"); - + if (message.data("isSystem")) { return; }*/ diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 8738dd93a..f20dbd015 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -591,7 +591,7 @@ async function sendWindowAIRequest(openai_msgs_tosend, signal, stream) { const onStreamResult = (res, err) => { if (err) { - handleWindowError(err); + return; } const thisContent = res?.message?.content; @@ -624,9 +624,9 @@ async function sendWindowAIRequest(openai_msgs_tosend, signal, stream) { resolve && resolve(content); }) .catch((err) => { - handleWindowError(err); finished = true; reject && reject(err); + handleWindowError(err); }); }; @@ -884,12 +884,7 @@ function countTokens(messages, full = false) { token_count += cachedCount; } else { - let model = oai_settings.openai_model; - - // We don't have a Claude tokenizer for JS yet. Turbo 3.5 should be able to handle this. - if (oai_settings.chat_completion_source == chat_completion_sources.CLAUDE) { - model = 'gpt-3.5-turbo'; - } + let model = getTokenizerModel(); jQuery.ajax({ async: false, @@ -911,6 +906,38 @@ function countTokens(messages, full = false) { return token_count; } +function getTokenizerModel() { + // OpenAI models always provide their own tokenizer + if (oai_settings.chat_completion_source == chat_completion_sources.OPENAI) { + return oai_settings.openai_model; + } + + const turboTokenizer = 'gpt-3.5-turbo' + // Select correct tokenizer for WindowAI proxies + if (oai_settings.chat_completion_source == chat_completion_sources.WINDOWAI) { + if (oai_settings.windowai_model.includes('gpt-4')) { + return 'gpt-4'; + } + else if (oai_settings.windowai_model.includes('gpt-3.5-turbo')) { + return turboTokenizer; + } + else if (oai_settings.windowai_model.includes('claude')) { + return turboTokenizer; + } + else if (oai_settings.windowai_model.includes('GPT-NeoXT')) { + return 'gpt2'; + } + } + + // We don't have a Claude tokenizer for JS yet. Turbo 3.5 should be able to handle this. + if (oai_settings.chat_completion_source == chat_completion_sources.CLAUDE) { + return turboTokenizer; + } + + // Default to Turbo 3.5 + return turboTokenizer; +} + function loadOpenAISettings(data, settings) { openai_setting_names = data.openai_setting_names; openai_settings = data.openai_settings; diff --git a/public/scripts/poe.js b/public/scripts/poe.js index a9e5bc19d..6261f2923 100644 --- a/public/scripts/poe.js +++ b/public/scripts/poe.js @@ -5,13 +5,16 @@ import { substituteParams, getRequestHeaders, max_context, + eventSource, + event_types, + scrollChatToBottom, } from "../script.js"; import { SECRET_KEYS, secret_state, writeSecret, } from "./secrets.js"; -import { splitRecursive } from "./utils.js"; +import { delay, splitRecursive } from "./utils.js"; export { is_get_status_poe, @@ -54,12 +57,14 @@ const poe_settings = { character_nudge: true, auto_purge: true, streaming: false, + suggest: false, }; let auto_jailbroken = false; let messages_to_purge = 0; let is_get_status_poe = false; let is_poe_button_press = false; +let abortControllerSuggest = null; function loadPoeSettings(settings) { if (settings.poe_settings) { @@ -74,15 +79,106 @@ function loadPoeSettings(settings) { $('#poe_auto_purge').prop('checked', poe_settings.auto_purge); $('#poe_streaming').prop('checked', poe_settings.streaming); $('#poe_impersonation_prompt').val(poe_settings.impersonation_prompt); + $('#poe_suggest').prop('checked', poe_settings.suggest); selectBot(); } +function abortSuggestedReplies() { + abortControllerSuggest && abortControllerSuggest.abort(); + $('.last_mes .suggested_replies').remove(); +} + function selectBot() { if (poe_settings.bot) { $('#poe_bots').find(`option[value="${poe_settings.bot}"]`).attr('selected', true); } } +function onSuggestedReplyClick() { + const reply = $(this).find('.suggested_reply_text').text(); + $("#send_textarea").val(reply); + $("#send_but").trigger('click'); +} + +function appendSuggestedReply(reply) { + if ($('.last_mes .suggested_replies').length === 0) { + $('.last_mes .mes_block').append(` +
+
+ `); + } + + const newElement = $(`
${reply}
`); + newElement.hide(); + $('.last_mes .suggested_replies').append(newElement); + newElement.fadeIn(500, async () => { + await delay(1); + scrollChatToBottom(); + }); +} + +async function suggestReplies(messageId) { + // If the feature is disabled + if (!poe_settings.suggest) { + return; + } + + // Cancel previous request + if (abortControllerSuggest) { + abortControllerSuggest.abort(); + } + + abortControllerSuggest = new AbortController(); + + abortControllerSuggest.signal.addEventListener('abort', () => { + // Hide suggestion UI + }); + + console.log('Querying suggestions for message', messageId); + + const response = await fetch(`/poe_suggest`, { + method: 'POST', + signal: abortControllerSuggest.signal, + headers: getRequestHeaders(), + body: JSON.stringify({ + messageId: messageId, + bot: poe_settings.bot, + }), + }); + + const decodeSuggestions = async function* () { + const decoder = new TextDecoder(); + const reader = response.body.getReader(); + + while (true) { + const { done, value } = await reader.read(); + let response = decoder.decode(value); + + const replies = response.split('\n\n'); + + for (let i = 0; i < replies.length - 1; i++) { + if (replies[i]) { + yield replies[i]; + } + } + + if (done) { + return; + } + } + } + + const suggestions = []; + + for await (const suggestion of decodeSuggestions()) { + suggestions.push(suggestion); + console.log('Got suggestion:', [suggestion]); + appendSuggestedReply(suggestion); + } + + return suggestions; +} + function onBotChange() { poe_settings.bot = $('#poe_bots').find(":selected").val(); saveSettingsDebounced(); @@ -127,7 +223,7 @@ async function onSendJailbreakClick() { async function autoJailbreak() { for (let retryNumber = 0; retryNumber < MAX_RETRIES_FOR_ACTIVATION; retryNumber++) { - const reply = await sendMessage(substituteParams(poe_settings.jailbreak_message), false); + const reply = await sendMessage(substituteParams(poe_settings.jailbreak_message), false, false); if (reply.toLowerCase().includes(poe_settings.jailbreak_response.toLowerCase())) { auto_jailbroken = true; @@ -171,13 +267,13 @@ async function generatePoe(type, finalPrompt, signal) { if (max_context > POE_TOKEN_LENGTH) { console.debug('Prompt is too long, sending in chunks'); - const result = await sendChunkedMessage(finalPrompt, !isQuiet, signal) + const result = await sendChunkedMessage(finalPrompt, !isQuiet, !isQuiet, signal) reply = result.reply; messages_to_purge = result.chunks + 1; // +1 for the reply } else { console.debug('Sending prompt in one message'); - reply = await sendMessage(finalPrompt, !isQuiet, signal); + reply = await sendMessage(finalPrompt, !isQuiet, !isQuiet, signal); messages_to_purge = 2; // prompt and the reply } @@ -195,12 +291,12 @@ async function sendChunkedMessage(finalPrompt, withStreaming, signal) { console.debug(`Sending chunk ${i + 1}/${promptChunks.length}: ${promptChunk}`); if (i == promptChunks.length - 1) { // Extract reply of the last chunk - reply = await sendMessage(promptChunk, withStreaming, signal); + reply = await sendMessage(promptChunk, withStreaming, true, signal); } else { // Add fast reply prompt to the chunk promptChunk += fastReplyPrompt; // Send chunk without streaming - const chunkReply = await sendMessage(promptChunk, false, signal); + const chunkReply = await sendMessage(promptChunk, false, false, signal); console.debug('Got chunk reply: ' + chunkReply); // Delete the reply for the chunk await purgeConversation(1); @@ -232,7 +328,7 @@ async function purgeConversation(count = -1) { return response.ok; } -async function sendMessage(prompt, withStreaming, signal) { +async function sendMessage(prompt, withStreaming, withSuggestions, signal) { if (!signal) { signal = new AbortController().signal; } @@ -250,6 +346,9 @@ async function sendMessage(prompt, withStreaming, signal) { signal: signal, }); + const messageId = response.headers.get('X-Message-Id'); + + if (withStreaming && poe_settings.streaming) { return async function* streamData() { const decoder = new TextDecoder(); @@ -261,6 +360,11 @@ async function sendMessage(prompt, withStreaming, signal) { getMessage += response; if (done) { + // Start suggesting only once the message is fully received + if (messageId && withSuggestions && poe_settings.suggest) { + suggestReplies(messageId); + } + return; } @@ -271,6 +375,10 @@ async function sendMessage(prompt, withStreaming, signal) { try { if (response.ok) { + if (messageId && withSuggestions && poe_settings.suggest) { + suggestReplies(messageId); + } + const data = await response.json(); return data.reply; } @@ -339,6 +447,8 @@ async function checkStatusPoe() { selectBot(); setOnlineStatus('Connected!'); + eventSource.on(event_types.CHAT_CHANGED, abortSuggestedReplies); + eventSource.on(event_types.MESSAGE_SWIPED, abortSuggestedReplies); } else { if (response.status == 401) { @@ -389,6 +499,15 @@ function onStreamingInput() { saveSettingsDebounced(); } +function onSuggestInput() { + poe_settings.suggest = !!$(this).prop('checked'); + saveSettingsDebounced(); + + if (!poe_settings.suggest) { + abortSuggestedReplies(); + } +} + function onImpersonationPromptInput() { poe_settings.impersonation_prompt = $(this).val(); saveSettingsDebounced(); @@ -435,4 +554,6 @@ $('document').ready(function () { $('#poe_activation_message_restore').on('click', onMessageRestoreClick); $('#poe_send_jailbreak').on('click', onSendJailbreakClick); $('#poe_purge_chat').on('click', onPurgeChatClick); + $('#poe_suggest').on('input', onSuggestInput); + $(document).on('click', '.suggested_reply', onSuggestedReplyClick); }); diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js index 376136782..e69f5ef9b 100644 --- a/public/scripts/power-user.js +++ b/public/scripts/power-user.js @@ -11,6 +11,7 @@ import { updateVisibleDivs, eventSource, event_types, + getCurrentChatId, } from "../script.js"; import { favsToHotswap } from "./RossAscends-mods.js"; import { @@ -933,6 +934,7 @@ $(document).ready(() => { $("#custom_chat_separator").on('input', function () { power_user.custom_chat_separator = $(this).val(); saveSettingsDebounced(); + reloadMarkdownProcessor(power_user.render_formulas); }); $("#multigen").change(function () { @@ -1155,6 +1157,13 @@ $(document).ready(() => { saveSettingsDebounced(); }); + $("#reload_chat").on('click', function () { + const currentChatId = getCurrentChatId(); + if (currentChatId !== undefined && currentChatId !== null) { + reloadCurrentChat(); + } + }); + $("#allow_name1_display").on("input", function () { power_user.allow_name1_display = !!$(this).prop('checked'); reloadCurrentChat(); diff --git a/public/scripts/showdown-dinkus.js b/public/scripts/showdown-dinkus.js new file mode 100644 index 000000000..597e40965 --- /dev/null +++ b/public/scripts/showdown-dinkus.js @@ -0,0 +1,19 @@ +import { power_user } from './power-user.js'; + +// Showdown extension to make chat separators (dinkuses) ignore markdown formatting +export const dinkusExtension = () => { + if (!power_user) { + console.log("Showdown-dinkus extension: power_user wasn't found! Returning."); + return [] + } + + // Create an escaped sequence so the regex can work with any character + const savedDinkus = power_user.custom_chat_separator + const escapedDinkus = savedDinkus.split('').map((e) => `\\${e}`).join(''); + const replaceRegex = new RegExp(`^(${escapedDinkus})\n`, "gm") + return [{ + type: "lang", + regex: replaceRegex, + replace: (match) => match.replace(replaceRegex, `
${savedDinkus}
`).trim() + }]; +} \ No newline at end of file diff --git a/public/style.css b/public/style.css index 1ad03f3a4..6351cda43 100644 --- a/public/style.css +++ b/public/style.css @@ -1488,6 +1488,30 @@ input[type=search]:focus::-webkit-search-cancel-button { display: none; } +.suggested_replies { + display: none; +} + +.last_mes .suggested_replies { + display: flex; + flex-direction: column; + gap: 5px; + margin-bottom: 5px; +} + +.suggested_reply { + display: flex; + padding: 5px; + margin-right: 5px; + border-radius: 5px; + font-weight: 500; + color: var(--SmartThemeQuoteColor); + border: 1px solid var(--white30a); + border-radius: 10px; + cursor: pointer; + transition: 0.2s; +} + .avatar_div .avatar { margin-left: 4px; margin-right: 10px; @@ -4570,4 +4594,4 @@ body.waifuMode #avatar_zoom_popup { overflow-y: auto; overflow-x: hidden; } -} \ No newline at end of file +} diff --git a/server.js b/server.js index f3a00497f..4a1902be4 100644 --- a/server.js +++ b/server.js @@ -2368,14 +2368,17 @@ app.post('/generate_poe', jsonParser, async (request, response) => { if (streaming) { try { - response.writeHead(200, { - 'Content-Type': 'text/plain;charset=utf-8', - 'Transfer-Encoding': 'chunked', - 'Cache-Control': 'no-transform', - }); - let reply = ''; for await (const mes of client.send_message(bot, prompt, false, 30, abortController.signal)) { + if (response.headersSent === false) { + response.writeHead(200, { + 'Content-Type': 'text/plain;charset=utf-8', + 'Transfer-Encoding': 'chunked', + 'Cache-Control': 'no-transform', + 'X-Message-Id': String(mes.messageId), + }); + } + if (isGenerationStopped) { console.error('Streaming stopped by user. Closing websocket...'); break; @@ -2398,11 +2401,14 @@ app.post('/generate_poe', jsonParser, async (request, response) => { else { try { let reply; + let messageId; for await (const mes of client.send_message(bot, prompt, false, 30, abortController.signal)) { reply = mes.text; + messageId = mes.messageId; } console.log(reply); //client.disconnect_ws(); + response.set('X-Message-Id', String(messageId)); return response.send({ 'reply': reply }); } catch { @@ -2412,6 +2418,93 @@ app.post('/generate_poe', jsonParser, async (request, response) => { } }); +app.post('/poe_suggest', jsonParser, async function(request, response) { + const token = readSecret(SECRET_KEYS.POE); + const messageId = request.body.messageId; + + if (!messageId) { + return response.sendStatus(400); + } + + if (!token) { + return response.sendStatus(401); + } + + try { + const bot = request.body.bot ?? POE_DEFAULT_BOT; + const client = await getPoeClient(token, true); + + response.writeHead(200, { + 'Content-Type': 'text/plain;charset=utf-8', + 'Transfer-Encoding': 'chunked', + 'Cache-Control': 'no-transform', + }); + + const botObject = client.bots[bot]; + const canSuggestReplies = botObject?.defaultBotObject?.hasSuggestedReplies ?? false; + + if (!canSuggestReplies) { + return response.end(); + } + + // Store replies that have already been sent to the user + const repliesSent = new Set(); + // Store the time when the request started + const beginAt = Date.now(); + while (true) { + // If more than 5 seconds have passed, stop suggesting replies + if (Date.now() - beginAt > 5000) { + break; + } + + // Get replies array from the Poe client + const suggestedReplies = client.suggested_replies[messageId]; + + // If the suggested replies array is not an array, wait 100ms and try again + if (!Array.isArray(suggestedReplies)) { + await delay(100); + continue; + } + + // If there are no replies, wait 100ms and try again + if (suggestedReplies.length === 0) { + await delay(100); + continue; + } + + // Send each reply to the user + for (const reply of suggestedReplies) { + // If the reply has already been sent, skip it + if (repliesSent.has(reply)) { + continue; + } + + // Add the reply to the list of replies that have been sent + repliesSent.add(reply); + // Write SSE event to the response stream + response.write(reply + '\n\n'); + } + + // Wait 100ms before checking for new replies + await delay(100); + } + + //client.disconnect_ws(); + return response.end(); + } + catch (err) { + console.error(err); + + if (response.headersSent === false) { + return response.sendStatus(401); + } else { + return response.end(); + } + } + + +}); + app.get('/discover_extensions', jsonParser, function (_, response) { const extensions = fs .readdirSync(directories.extensions) diff --git a/src/poe-client.js b/src/poe-client.js index dc7c30f62..cc44225bd 100644 --- a/src/poe-client.js +++ b/src/poe-client.js @@ -293,6 +293,8 @@ class Client { bots = {}; active_messages = {}; message_queues = {}; + suggested_replies = {}; + suggested_replies_updated = {}; bot_names = []; ws = null; ws_connected = false; @@ -558,6 +560,9 @@ class Client { try { const data = JSON.parse(msg); + // Uncomment to debug websocket messages + //console.log(data); + if (!('messages' in data)) { return; } @@ -575,6 +580,11 @@ class Client { return; } + if ("suggestedReplies" in message && Array.isArray(message["suggestedReplies"])) { + this.suggested_replies[message["messageId"]] = [...message["suggestedReplies"]]; + this.suggested_replies_updated[message["messageId"]] = Date.now(); + } + const copiedDict = Object.assign({}, this.active_messages); for (const [key, value] of Object.entries(copiedDict)) { //add the message to the appropriate queue