diff --git a/css/main.css b/css/main.css index be27544c..9d68ba02 100644 --- a/css/main.css +++ b/css/main.css @@ -1457,6 +1457,53 @@ strong { color: #ccc; } +/* Message Editing Styles */ +.editing-textarea { + width: 100%; + min-height: 200px; + padding: 10px; + border-radius: 5px; + border: 1px solid #ccc; + background-color: var(--light-theme-gray); + font-family: inherit; + font-size: inherit; + resize: vertical; +} + +.dark .editing-textarea { + border: 1px solid var(--border-color-dark); + background-color: var(--darker-gray); +} + +.editing-textarea:focus { + outline: none; + border-color: var(--selected-item-color-dark); +} + +.edit-controls-container { + margin-top: 0; + display: flex; + gap: 8px; + padding-bottom: 8px; +} + +.edit-control-button { + padding: 6px 12px; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + background-color: #f8f9fa; + color: #212529; + font-size: 12px; + margin: 0; +} + +.dark .edit-control-button { + border: 1px solid var(--border-color-dark); + background-color: var(--light-gray); + color: #efefef; +} + /* --- Simple Version Navigation --- */ .version-navigation { position: absolute; @@ -1488,7 +1535,7 @@ strong { .version-position { font-size: 11px; - color: currentColor; + color: currentcolor; font-family: monospace; min-width: 35px; text-align: center; diff --git a/js/global_scope_js.js b/js/global_scope_js.js index 9174622e..0e86d450 100644 --- a/js/global_scope_js.js +++ b/js/global_scope_js.js @@ -1,3 +1,7 @@ +// ------------------------------------------------- +// Event handlers +// ------------------------------------------------- + function copyToClipboard(element) { if (!element) return; @@ -42,11 +46,135 @@ function branchHere(element) { branchIndexInput.value = index; // Trigger any 'change' or 'input' events Gradio might be listening for - const event = new Event("input", { bubbles: true }); // 'change' might also work + const event = new Event("input", { bubbles: true }); branchIndexInput.dispatchEvent(event); - branchButton.click(); // Gradio will now pick up the 'index' + branchButton.click(); +} +// ------------------------------------------------- +// Message Editing Functions +// ------------------------------------------------- + +function editHere(buttonElement) { + if (!buttonElement) return; + + const messageElement = buttonElement.closest(".message, .user-message, .assistant-message"); + if (!messageElement) return; + + const messageBody = messageElement.querySelector(".message-body"); + if (!messageBody) return; + + // If already editing, focus the textarea + const existingTextarea = messageBody.querySelector(".editing-textarea"); + if (existingTextarea) { + existingTextarea.focus(); + return; + } + + // Determine role based on message element - handle different chat modes + const isUserMessage = messageElement.classList.contains("user-message") || + messageElement.querySelector(".text-you") !== null || + messageElement.querySelector(".circle-you") !== null; + + startEditing(messageElement, messageBody, isUserMessage); +} + +function startEditing(messageElement, messageBody, isUserMessage) { + const rawText = messageElement.getAttribute("data-raw") || messageBody.textContent; + const originalHTML = messageBody.innerHTML; + + // Create editing interface + const editingInterface = createEditingInterface(rawText); + + // Replace message content + messageBody.innerHTML = ""; + messageBody.appendChild(editingInterface.textarea); + messageBody.appendChild(editingInterface.controls); + + editingInterface.textarea.focus(); + editingInterface.textarea.setSelectionRange(rawText.length, rawText.length); + + // Setup event handlers + setupEditingHandlers(editingInterface.textarea, messageElement, originalHTML, messageBody, isUserMessage); +} + +function createEditingInterface(text) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.className = "editing-textarea"; + textarea.rows = Math.max(3, text.split("\n").length); + + const controls = document.createElement("div"); + controls.className = "edit-controls-container"; + + const saveButton = document.createElement("button"); + saveButton.textContent = "Save"; + saveButton.className = "edit-control-button"; + saveButton.type = "button"; + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "edit-control-button edit-cancel-button"; + cancelButton.type = "button"; + + controls.appendChild(saveButton); + controls.appendChild(cancelButton); + + return { textarea, controls, saveButton, cancelButton }; +} + +function setupEditingHandlers(textarea, messageElement, originalHTML, messageBody, isUserMessage) { + const saveButton = messageBody.querySelector(".edit-control-button:not(.edit-cancel-button)"); + const cancelButton = messageBody.querySelector(".edit-cancel-button"); + + const submitEdit = () => { + const index = messageElement.getAttribute("data-index"); + if (!index || !submitMessageEdit(index, textarea.value, isUserMessage)) { + cancelEdit(); + } + }; + + const cancelEdit = () => { + messageBody.innerHTML = originalHTML; + }; + + // Event handlers + saveButton.onclick = submitEdit; + cancelButton.onclick = cancelEdit; + + textarea.onkeydown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submitEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEdit(); + } + }; +} + +function submitMessageEdit(index, newText, isUserMessage) { + const editIndexInput = document.getElementById("Edit-message-index")?.querySelector("input"); + const editTextInput = document.getElementById("Edit-message-text")?.querySelector("textarea"); + const editRoleInput = document.getElementById("Edit-message-role")?.querySelector("textarea"); + const editButton = document.getElementById("Edit-message"); + + if (!editIndexInput || !editTextInput || !editRoleInput || !editButton) { + console.error("Edit elements not found"); + return false; + } + + editIndexInput.value = index; + editTextInput.value = newText; + editRoleInput.value = isUserMessage ? "user" : "assistant"; + + editIndexInput.dispatchEvent(new Event("input", { bubbles: true })); + editTextInput.dispatchEvent(new Event("input", { bubbles: true })); + editRoleInput.dispatchEvent(new Event("input", { bubbles: true })); + + editButton.click(); + return true; } function navigateVersion(element, direction) { diff --git a/js/main.js b/js/main.js index d90e8ade..fc014f66 100644 --- a/js/main.js +++ b/js/main.js @@ -1,3 +1,7 @@ +// ------------------------------------------------ +// Main +// ------------------------------------------------ + let main_parent = document.getElementById("chat-tab").parentNode; let extensions = document.getElementById("extensions"); @@ -102,18 +106,6 @@ document.addEventListener("keydown", function(event) { document.getElementById("Remove-last").click(); } - // Copy last on Ctrl + Shift + K - else if (event.ctrlKey && event.shiftKey && event.key === "K") { - event.preventDefault(); - document.getElementById("Copy-last").click(); - } - - // Replace last on Ctrl + Shift + L - else if (event.ctrlKey && event.shiftKey && event.key === "L") { - event.preventDefault(); - document.getElementById("Replace-last").click(); - } - // Impersonate on Ctrl + Shift + M else if (event.ctrlKey && event.shiftKey && event.key === "M") { event.preventDefault(); @@ -388,6 +380,16 @@ document.addEventListener("click", function (event) { } }); +document.addEventListener("dblclick", (event) => { + const messageElement = event.target.closest(".message, .user-message, .assistant-message"); + if (!messageElement) return; + + const editButton = messageElement.querySelector(".footer-edit-button"); + if (editButton) { + editButton.click(); + } +}); + //------------------------------------------------ // Relocate the "Show controls" checkbox //------------------------------------------------ diff --git a/modules/chat.py b/modules/chat.py index 6eed47ee..9598efa7 100644 --- a/modules/chat.py +++ b/modules/chat.py @@ -712,32 +712,6 @@ def remove_last_message(history): return html.unescape(last[0]), history -def send_last_reply_to_input(history): - if len(history['visible']) > 0: - return html.unescape(history['visible'][-1][1]) - else: - return '' - - -def replace_last_reply(textbox, state): - history = state['history'] - text = textbox['text'] - - # Initialize metadata if not present - if 'metadata' not in history: - history['metadata'] = {} - - if len(text.strip()) == 0: - return history - elif len(history['visible']) > 0: - row_idx = len(history['internal']) - 1 - history['visible'][-1][1] = html.escape(text) - history['internal'][-1][1] = apply_extensions('input', text, state, is_chat=True) - update_message_metadata(history['metadata'], "assistant", row_idx, timestamp=get_current_timestamp()) - - return history - - def send_dummy_message(textbox, state): history = state['history'] text = textbox['text'] @@ -1330,14 +1304,6 @@ def my_yaml_output(data): return result -def handle_replace_last_reply_click(text, state): - history = replace_last_reply(text, state) - save_history(history, state['unique_id'], state['character_menu'], state['mode']) - html = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu']) - - return [history, html, {"text": "", "files": []}] - - def handle_send_dummy_message_click(text, state): history = send_dummy_message(text, state) save_history(history, state['unique_id'], state['character_menu'], state['mode']) @@ -1425,6 +1391,52 @@ def handle_branch_chat_click(state): return [history, html, past_chats_update, -1] +def handle_edit_message_click(state): + history = state['history'] + message_index = int(state['edit_message_index']) + new_text = state['edit_message_text'] + role = state['edit_message_role'] # "user" or "assistant" + + if message_index >= len(history['internal']): + html_output = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu']) + return [history, html_output, gr.update()] + + # Use the role passed from frontend + is_user_msg = (role == "user") + role_idx = 0 if is_user_msg else 1 + + # For assistant messages, save the original version BEFORE updating content + if not is_user_msg: + if not history['metadata'].get(f"assistant_{message_index}", {}).get('versions'): + add_message_version(history, message_index, is_current=False) + + # NOW update the message content + history['internal'][message_index][role_idx] = apply_extensions('input', new_text, state, is_chat=True) + history['visible'][message_index][role_idx] = html.escape(new_text) + + # Branch if editing user message, add version if editing assistant message + if is_user_msg: + # Branch like branch-here + history['visible'] = history['visible'][:message_index + 1] + history['internal'] = history['internal'][:message_index + 1] + new_unique_id = datetime.now().strftime('%Y%m%d-%H-%M-%S') + save_history(history, new_unique_id, state['character_menu'], state['mode']) + histories = find_all_histories_with_first_prompts(state) + past_chats_update = gr.update(choices=histories, value=new_unique_id) + state['unique_id'] = new_unique_id + elif not is_user_msg: + # Add the new version as current + add_message_version(history, message_index, is_current=True) + past_chats_update = gr.update() + else: + past_chats_update = gr.update() + + save_history(history, state['unique_id'], state['character_menu'], state['mode']) + html_output = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu']) + + return [history, html_output, past_chats_update] + + def handle_navigate_version_click(state): history = state['history'] message_index = int(state['navigate_message_index']) diff --git a/modules/html_generator.py b/modules/html_generator.py index 1dfeb445..9a93555f 100644 --- a/modules/html_generator.py +++ b/modules/html_generator.py @@ -336,12 +336,14 @@ refresh_svg = '''''' remove_svg = '''''' branch_svg = '''''' +edit_svg = '''''' info_svg = '''''' info_svg_small = '''''' attachment_svg = '''''' copy_button = f'' branch_button = f'' +edit_button = f'' refresh_button = f'' continue_button = f'' remove_button = f'' @@ -404,16 +406,23 @@ def get_version_navigation_html(history, i): return f'
{left_arrow}{position}{right_arrow}
' -def actions_html(history, i, info_message=""): +def actions_html(history, i, role, info_message=""): + if role == "assistant": + return (f'
' + f'{copy_button}' + f'{edit_button}' + f'{refresh_button if i == len(history["visible"]) - 1 else ""}' + f'{continue_button if i == len(history["visible"]) - 1 else ""}' + f'{remove_button if i == len(history["visible"]) - 1 else ""}' + f'{branch_button}' + f'{info_message}' + f'
' + f'{get_version_navigation_html(history, i)}') return (f'
' f'{copy_button}' - f'{refresh_button if i == len(history["visible"]) - 1 else ""}' - f'{continue_button if i == len(history["visible"]) - 1 else ""}' - f'{remove_button if i == len(history["visible"]) - 1 else ""}' - f'{branch_button}' + f'{edit_button}' f'{info_message}' - f'
' - f'{get_version_navigation_html(history, i)}') + f'') def generate_instruct_html(history): @@ -448,11 +457,12 @@ def generate_instruct_html(history): if converted_visible[0]: # Don't display empty user messages output += ( f'
' + f'data-raw="{html.escape(row_internal[0], quote=True)}"' + f'data-index={i}>' f'
' f'
{converted_visible[0]}
' f'{user_attachments}' - f'
{copy_button}{info_message_user}
' + f'{actions_html(history, i, "user", info_message_user)}' f'
' f'
' ) @@ -464,7 +474,7 @@ def generate_instruct_html(history): f'
' f'
{converted_visible[1]}
' f'{assistant_attachments}' - f'{actions_html(history, i, info_message_assistant)}' + f'{actions_html(history, i, "assistant", info_message_assistant)}' f'
' f'' ) @@ -503,13 +513,14 @@ def generate_cai_chat_html(history, name1, name2, style, character, reset_cache= if converted_visible[0]: # Don't display empty user messages output += ( f'
' + f'data-raw="{html.escape(row_internal[0], quote=True)}"' + f'data-index={i}>' f'
{img_me}
' f'
' f'
{name1}{user_timestamp}
' f'
{converted_visible[0]}
' f'{user_attachments}' - f'
{copy_button}
' + f'{actions_html(history, i, "user")}' f'
' f'
' ) @@ -523,7 +534,7 @@ def generate_cai_chat_html(history, name1, name2, style, character, reset_cache= f'
{name2}{assistant_timestamp}
' f'
{converted_visible[1]}
' f'{assistant_attachments}' - f'{actions_html(history, i)}' + f'{actions_html(history, i, "assistant")}' f'' f'' ) @@ -564,11 +575,12 @@ def generate_chat_html(history, name1, name2, reset_cache=False): if converted_visible[0]: # Don't display empty user messages output += ( f'
' + f'data-raw="{html.escape(row_internal[0], quote=True)}"' + f'data-index={i}>' f'
' f'
{converted_visible[0]}
' f'{user_attachments}' - f'
{copy_button}{info_message_user}
' + f'{actions_html(history, i, "user", info_message_user)}' f'
' f'
' ) @@ -580,7 +592,7 @@ def generate_chat_html(history, name1, name2, reset_cache=False): f'
' f'
{converted_visible[1]}
' f'{assistant_attachments}' - f'{actions_html(history, i, info_message_assistant)}' + f'{actions_html(history, i, "assistant", info_message_assistant)}' f'
' f'' ) diff --git a/modules/ui.py b/modules/ui.py index 52c095a2..00393b53 100644 --- a/modules/ui.py +++ b/modules/ui.py @@ -212,7 +212,12 @@ def list_interface_input_elements(): 'negative_prompt', 'dry_sequence_breakers', 'grammar_string', - 'branch_index' + 'navigate_message_index', + 'navigate_direction', + 'edit_message_index', + 'edit_message_text', + 'edit_message_role', + 'branch_index', ] # Chat elements diff --git a/modules/ui_chat.py b/modules/ui_chat.py index 7a9f6f76..2856ce1f 100644 --- a/modules/ui_chat.py +++ b/modules/ui_chat.py @@ -71,8 +71,6 @@ def create_ui(): shared.gradio['Remove last'] = gr.Button('Remove last reply (Ctrl + Shift + Backspace)', elem_id='Remove-last') with gr.Row(): - shared.gradio['Replace last reply'] = gr.Button('Replace last reply (Ctrl + Shift + L)', elem_id='Replace-last') - shared.gradio['Copy last reply'] = gr.Button('Copy last reply (Ctrl + Shift + K)', elem_id='Copy-last') shared.gradio['Impersonate'] = gr.Button('Impersonate (Ctrl + Shift + M)', elem_id='Impersonate') with gr.Row(): @@ -97,11 +95,15 @@ def create_ui(): with gr.Row(): shared.gradio['chat-instruct_command'] = gr.Textbox(value=shared.settings['chat-instruct_command'], lines=12, label='Command for chat-instruct mode', info='<|character|> and <|prompt|> get replaced with the bot name and the regular chat prompt respectively.', visible=shared.settings['mode'] == 'chat-instruct', elem_classes=['add_scrollbar']) - # Hidden elements for version navigation (similar to branch) + # Hidden elements for version navigation and editing with gr.Row(visible=False): shared.gradio['navigate_message_index'] = gr.Number(value=-1, precision=0, elem_id="Navigate-message-index") shared.gradio['navigate_direction'] = gr.Textbox(value="", elem_id="Navigate-direction") shared.gradio['navigate_version'] = gr.Button(elem_id="Navigate-version") + shared.gradio['edit_message_index'] = gr.Number(value=-1, precision=0, elem_id="Edit-message-index") + shared.gradio['edit_message_text'] = gr.Textbox(value="", elem_id="Edit-message-text") + shared.gradio['edit_message_role'] = gr.Textbox(value="", elem_id="Edit-message-role") + shared.gradio['edit_message'] = gr.Button(elem_id="Edit-message") def create_chat_settings_ui(): @@ -228,10 +230,6 @@ def create_event_handlers(): None, None, None, js='() => document.getElementById("chat").parentNode.parentNode.parentNode.classList.remove("_generating")').then( None, None, None, js=f'() => {{{ui.audio_notification_js}}}') - shared.gradio['Replace last reply'].click( - ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then( - chat.handle_replace_last_reply_click, gradio('textbox', 'interface_state'), gradio('history', 'display', 'textbox'), show_progress=False) - shared.gradio['Send dummy message'].click( ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then( chat.handle_send_dummy_message_click, gradio('textbox', 'interface_state'), gradio('history', 'display', 'textbox'), show_progress=False) @@ -297,12 +295,16 @@ def create_event_handlers(): None, gradio('mode'), None, js="(mode) => {mode === 'instruct' ? document.getElementById('character-menu').parentNode.parentNode.style.display = 'none' : document.getElementById('character-menu').parentNode.parentNode.style.display = ''}") shared.gradio['chat_style'].change(chat.redraw_html, gradio(reload_arr), gradio('display'), show_progress=False) - shared.gradio['Copy last reply'].click(chat.send_last_reply_to_input, gradio('history'), gradio('textbox'), show_progress=False) shared.gradio['navigate_version'].click( ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then( chat.handle_navigate_version_click, gradio('interface_state'), gradio('history', 'display'), show_progress=False) + shared.gradio['edit_message'].click( + ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then( + chat.handle_edit_message_click, gradio('interface_state'), gradio('history', 'display', 'unique_id'), show_progress=False).then( + lambda: None, None, None, js='() => { const role = document.getElementById("Edit-message-role").querySelector("textarea").value; if (role === "user") document.getElementById("Regenerate").click(); }') + # Save/delete a character shared.gradio['save_character'].click(chat.handle_save_character_click, gradio('name2'), gradio('save_character_filename', 'character_saver'), show_progress=False) shared.gradio['delete_character'].click(lambda: gr.update(visible=True), None, gradio('character_deleter'), show_progress=False)