This commit is contained in:
Underscore 2025-05-21 18:27:59 -04:00 committed by GitHub
commit 8906f55aa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 814 additions and 12 deletions

View file

@ -1412,6 +1412,7 @@ strong {
color: #07ff07; color: #07ff07;
} }
.message-attachments { .message-attachments {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1456,3 +1457,96 @@ strong {
.dark .attachment-icon { .dark .attachment-icon {
color: #ccc; color: #ccc;
} }
/* --- Message Versioning Styles --- */
.message-versioning-container {
position: absolute;
bottom: -23px;
right: 0;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.message:hover .message-versioning-container,
.user-message:hover .message-versioning-container,
.assistant-message:hover .message-versioning-container {
opacity: 1;
pointer-events: auto;
}
.selected-message .message-versioning-container {
opacity: 1;
pointer-events: auto;
}
.message-versioning-nav-container {
display: flex;
align-items: center;
gap: 5px;
}
.message-versioning-nav-arrow {
position: static;
margin: 0;
padding: 2px;
width: 20px;
height: 20px;
line-height: 0;
transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out;
}
.message .message-versioning-nav-arrow:not([activated]),
.user-message .message-versioning-nav-arrow:not([activated]),
.assistant-message .message-versioning-nav-arrow:not([activated]) {
cursor: not-allowed;
opacity: 0.3;
transform: scale(0.95);
}
.message .message-versioning-nav-arrow:not([activated]):hover,
.user-message .message-versioning-nav-arrow:not([activated]):hover,
.assistant-message .message-versioning-nav-arrow:not([activated]):hover {
opacity: 0.3;
}
.message-versioning-nav-pos {
font-size: 11px;
font-family: monospace;
color: rgb(156 163 175); /* Match SVG stroke color */
user-select: none;
min-width: 25px;
text-align: center;
line-height: 20px;
padding: 0 2px;
}
.dark .message-versioning-nav-pos {
color: rgb(156 163 175); /* Same color for dark mode */
}
/* If you want the other buttons to be visible when a message is selected, uncomment the lines below */
.selected-message:not(:hover) .message-versioning-nav-arrow[activated]/*,
.selected-message:not(:hover) .footer-button*/ {
opacity: 0.4;
}
.selected-message:not(:hover) .message-versioning-nav-arrow:not([activated]) {
opacity: 0.1;
}
.selected-message:hover .message-versioning-nav-arrow[activated]/*,
.selected-message:hover .footer-button*/ {
opacity: 1;
}
.selected-message:hover .message-versioning-nav-arrow:not([activated]) {
opacity: 0.3;
}
.message-versioning-container[hidden] {
display: none;

View file

@ -61,6 +61,8 @@ function removeLastClick() {
document.getElementById("Remove-last").click(); document.getElementById("Remove-last").click();
} }
// === History navigation handled in main.js === //
function handleMorphdomUpdate(text) { function handleMorphdomUpdate(text) {
// Track open blocks // Track open blocks
const openBlocks = new Set(); const openBlocks = new Set();

View file

@ -789,3 +789,150 @@ function createMobileTopBar() {
} }
createMobileTopBar(); createMobileTopBar();
//------------------------------------------------
// Message Versioning Integration
//------------------------------------------------
// --- Message Versioning Variables ---
// let versioningSelectedMessageElement = null; // Deprecated due to persistent selection state
let selectedMessageHistoryIndex = null;
let selectedMessageType = null;
// --- Message Versioning Helper Functions ---
// Helper function to get Gradio app root (if needed, otherwise use document)
function gradioApp() {
const elems = document.querySelectorAll('gradio-app');
const gradioShadowRoot = elems.length > 0 ? elems[0].shadowRoot : null;
return gradioShadowRoot || document;
}
// Helper to update Gradio text/number inputs (if needed for backend communication)
function updateVersioningGradioInput(element, value) {
if (element) {
element.value = value;
element.dispatchEvent(new Event('input', { bubbles: true }));
} else {
console.warn("Attempted to update a null Gradio input element.");
}
}
// --- Message Versioning Core Functions ---
function triggerVersionNavigateBackend(historyIndex, messageType, direction) {
console.log(`DEBUG: Triggering version navigate backend with historyIndex: ${historyIndex}, messageType: ${messageType}, direction: ${direction}`);
const gradio = gradioApp();
const historyIndexInput = gradio.querySelector('#message-versioning-history-index-hidden input[type="number"]');
const messageTypeInput = gradio.querySelector('#message-versioning-message-type-hidden input[type="number"]');
const directionInput = gradio.querySelector('#message-versioning-direction-hidden textarea');
const navigateButton = gradio.querySelector('#message-versioning-navigate-hidden');
if (historyIndexInput && messageTypeInput && directionInput && navigateButton) {
console.log("DEBUG: Found hidden Gradio elements for navigation.");
updateVersioningGradioInput(historyIndexInput, historyIndex);
updateVersioningGradioInput(messageTypeInput, messageType);
updateVersioningGradioInput(directionInput, direction);
navigateButton.click();
} else {
console.error("Message Versioning: Could not find hidden Gradio elements for navigation. Backend communication needs setup.");
// Fallback or error handling: Log a warning?
}
}
// Function called by the nav arrow's onclick attribute
function versioningNavigateClick(arrowButton, historyIndex, messageType, direction) {
// Keep the message selected
const messageElement = arrowButton.closest('.message, .user-message, .assistant-message');
if (messageElement) {
versioningSelectMessage(messageElement, historyIndex, messageType);
}
triggerVersionNavigateBackend(historyIndex, messageType, direction);
}
function versioningSelectMessage(element, historyIndex, messageType) {
// Remove previous selection
versioningDeselectMessages();
if (element) {
// versioningSelectedMessageElement = element;
selectedMessageHistoryIndex = historyIndex;
selectedMessageType = messageType;
element.classList.add('selected-message');
}
}
function versioningDeselectMessages() {
const selectedMessageElement = gradioApp().querySelector('#chat .selected-message');
// if (versioningSelectedMessageElement) {
// versioningSelectedMessageElement.classList.remove('selected-message');
if (selectedMessageElement) {
selectedMessageElement.classList.remove('selected-message');
}
// versioningSelectedMessageElement = null;
selectedMessageHistoryIndex = null;
selectedMessageType = null;
}
// --- Message Versioning Global Listeners ---
// Global click listener for buttons and (de)selecting messages
document.addEventListener('click', function(e) {
const target = e.target;
const msg = target.closest('.message, .user-message, .assistant-message')
if (msg) {
const historyIndex = msg.getAttribute('data-history-index');
const msgType = msg.getAttribute('data-message-type') ?? (msg.classList.contains('assistant-message') ? 1 : 0);
if (target.closest('button')) {
const button = target.closest('.message-versioning-nav-arrow');
if (button && button.hasAttribute('activated')) {
const direction = button.getAttribute('data-direction');
versioningNavigateClick(button, parseFloat(historyIndex), parseFloat(msgType), direction);
}
} else if (msg.classList.contains('selected-message') && !e.ctrlKey) {
versioningDeselectMessages();
} else {
versioningSelectMessage(msg, parseInt(historyIndex), parseInt(msgType));
}
} else if (target.closest('#chat') && !target.closest('#message-versioning-navigate-hidden')) { // Deselect if the click is in-chat, outside a message
versioningDeselectMessages();
}
});
// Global keydown listener for keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
// Use Ctrl + Left/Right Arrow Keys for navigation, Ctrl + Up/Down Arrow Keys for selection
if (e.ctrlKey && !e.shiftKey) {
if (e.key === 'ArrowLeft') {
triggerVersionNavigateBackend(selectedMessageHistoryIndex, selectedMessageType, 'left');
e.preventDefault();
} else if (e.key === 'ArrowRight') {
triggerVersionNavigateBackend(selectedMessageHistoryIndex, selectedMessageType, 'right');
e.preventDefault();
} else if (e.key === 'ArrowUp') {
selectRelativeMessage(-1)
e.preventDefault();
} else if (e.key === 'ArrowDown') {
selectRelativeMessage(1)
e.preventDefault();
}
function selectRelativeMessage(offset) {
const chat = gradioApp().querySelector('#chat');
if (!chat) return;
const messages = Array.from(chat.querySelectorAll('.message, .user-message, .assistant-message'));
if (messages.length === 0) return;
const selectedMessageChatIndex = messages.findIndex(msg => msg.classList.contains('selected-message')); // Could be saved in a variable rather than run each time
const index = selectedMessageChatIndex + offset;
if (index >= 0 && index < messages.length) messages[index]?.click();
}
}
});

View file

@ -17,7 +17,7 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment
from PIL import Image from PIL import Image
import modules.shared as shared import modules.shared as shared
from modules import utils from modules import utils, message_versioning
from modules.extensions import apply_extensions from modules.extensions import apply_extensions
from modules.html_generator import ( from modules.html_generator import (
chat_html_wrapper, chat_html_wrapper,
@ -667,6 +667,14 @@ def generate_chat_reply_wrapper(text, state, regenerate=False, _continue=False):
send_dummy_reply(state['start_with'], state) send_dummy_reply(state['start_with'], state)
history = state['history'] history = state['history']
initial_history_len = len(history['internal'])
if not regenerate and not _continue and text.strip():
temp_history = copy.deepcopy(history)
visible_text = html.escape(text)
temp_history['internal'].append([text, ''])
temp_history['visible'].append([visible_text, ''])
message_versioning.append_message_version(temp_history, state, is_bot=False)
last_save_time = time.monotonic() last_save_time = time.monotonic()
save_interval = 8 save_interval = 8
for i, history in enumerate(generate_chat_reply(text, state, regenerate, _continue, loading_message=True, for_ui=True)): for i, history in enumerate(generate_chat_reply(text, state, regenerate, _continue, loading_message=True, for_ui=True)):
@ -677,9 +685,14 @@ def generate_chat_reply_wrapper(text, state, regenerate=False, _continue=False):
if i == 0 or (current_time - last_save_time) >= save_interval: if i == 0 or (current_time - last_save_time) >= save_interval:
save_history(history, state['unique_id'], state['character_menu'], state['mode']) save_history(history, state['unique_id'], state['character_menu'], state['mode'])
last_save_time = current_time last_save_time = current_time
if len(history['internal']) > initial_history_len or regenerate or _continue:
message_versioning.append_message_version(history, state, is_bot=True)
save_history(history, state['unique_id'], state['character_menu'], state['mode']) save_history(history, state['unique_id'], state['character_menu'], state['mode'])
yield chat_html_wrapper(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu']), history
def remove_last_message(history): def remove_last_message(history):
if 'metadata' not in history: if 'metadata' not in history:
@ -822,6 +835,7 @@ def rename_history(old_id, new_id, character, mode):
else: else:
logger.info(f"Renaming \"{old_p}\" to \"{new_p}\"") logger.info(f"Renaming \"{old_p}\" to \"{new_p}\"")
old_p.rename(new_p) old_p.rename(new_p)
message_versioning.rename_history_data(old_id, new_id, character, mode)
def get_paths(state): def get_paths(state):
@ -1317,8 +1331,11 @@ def my_yaml_output(data):
def handle_replace_last_reply_click(text, state): def handle_replace_last_reply_click(text, state):
last_reply = state['history']['internal'][-1][1] if len(state['history']['internal']) > 0 else None
history = replace_last_reply(text, state) history = replace_last_reply(text, state)
save_history(history, state['unique_id'], state['character_menu'], state['mode']) save_history(history, state['unique_id'], state['character_menu'], state['mode'])
if len(history['internal']) > 0 and history['internal'][-1][1] != last_reply: # Differs from last reply
message_versioning.append_message_version(history, state, is_bot=True)
html = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu']) html = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu'])
return [history, html, {"text": "", "files": []}] return [history, html, {"text": "", "files": []}]
@ -1327,6 +1344,7 @@ def handle_replace_last_reply_click(text, state):
def handle_send_dummy_message_click(text, state): def handle_send_dummy_message_click(text, state):
history = send_dummy_message(text, state) history = send_dummy_message(text, state)
save_history(history, state['unique_id'], state['character_menu'], state['mode']) save_history(history, state['unique_id'], state['character_menu'], state['mode'])
message_versioning.append_message_version(history, state, is_bot=False)
html = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu']) html = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu'])
return [history, html, {"text": "", "files": []}] return [history, html, {"text": "", "files": []}]
@ -1335,6 +1353,7 @@ def handle_send_dummy_message_click(text, state):
def handle_send_dummy_reply_click(text, state): def handle_send_dummy_reply_click(text, state):
history = send_dummy_reply(text, state) history = send_dummy_reply(text, state)
save_history(history, state['unique_id'], state['character_menu'], state['mode']) save_history(history, state['unique_id'], state['character_menu'], state['mode'])
message_versioning.append_message_version(history, state, is_bot=True)
html = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu']) html = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu'])
return [history, html, {"text": "", "files": []}] return [history, html, {"text": "", "files": []}]
@ -1373,8 +1392,18 @@ def handle_start_new_chat_click(state):
def handle_delete_chat_confirm_click(state): def handle_delete_chat_confirm_click(state):
index = str(find_all_histories(state).index(state['unique_id'])) unique_id_to_delete = state['unique_id']
delete_history(state['unique_id'], state['character_menu'], state['mode']) character_to_delete = state['character_menu']
mode_to_delete = state['mode']
all_histories = find_all_histories(state)
index = '0'
if unique_id_to_delete in all_histories:
index = str(all_histories.index(unique_id_to_delete))
delete_history(unique_id_to_delete, character_to_delete, mode_to_delete)
message_versioning.clear_history_data(unique_id_to_delete, character_to_delete, mode_to_delete)
# Load the next appropriate history
history, unique_id = load_history_after_deletion(state, index) history, unique_id = load_history_after_deletion(state, index)
html = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu']) html = redraw_html(history, state['name1'], state['name2'], state['mode'], state['chat_style'], state['character_menu'])

View file

@ -9,7 +9,7 @@ from pathlib import Path
import markdown import markdown
from PIL import Image, ImageOps from PIL import Image, ImageOps
from modules import shared from modules import shared, message_versioning
from modules.sane_markdown_lists import SaneListExtension from modules.sane_markdown_lists import SaneListExtension
from modules.utils import get_available_chat_styles from modules.utils import get_available_chat_styles
@ -379,7 +379,6 @@ def format_message_attachments(history, role, index):
return "" return ""
def actions_html(history, i, info_message=""): def actions_html(history, i, info_message=""):
return (f'<div class="message-actions">' return (f'<div class="message-actions">'
f'{copy_button}' f'{copy_button}'
@ -398,6 +397,8 @@ def generate_instruct_html(history):
row_visible = history['visible'][i] row_visible = history['visible'][i]
row_internal = history['internal'][i] row_internal = history['internal'][i]
converted_visible = [convert_to_markdown_wrapped(entry, message_id=i, use_cache=i != len(history['visible']) - 1) for entry in row_visible] converted_visible = [convert_to_markdown_wrapped(entry, message_id=i, use_cache=i != len(history['visible']) - 1) for entry in row_visible]
versioning_nav_user = message_versioning.get_message_version_nav_elements(i, 0)
versioning_nav_bot = message_versioning.get_message_version_nav_elements(i, 1)
# Get timestamps # Get timestamps
user_timestamp = format_message_timestamp(history, "user", i) user_timestamp = format_message_timestamp(history, "user", i)
@ -421,8 +422,10 @@ def generate_instruct_html(history):
info_message_assistant = info_button.replace("message", assistant_timestamp_value) info_message_assistant = info_button.replace("message", assistant_timestamp_value)
if converted_visible[0]: # Don't display empty user messages if converted_visible[0]: # Don't display empty user messages
selected_class = " selected-message" if message_versioning.is_message_selected(i, 0) else ""
output += ( output += (
f'<div class="user-message" ' f'<div class="user-message{selected_class}" '
f'data-history-index="{i}" '
f'data-raw="{html.escape(row_internal[0], quote=True)}">' f'data-raw="{html.escape(row_internal[0], quote=True)}">'
f'<div class="text">' f'<div class="text">'
f'<div class="message-body">{converted_visible[0]}</div>' f'<div class="message-body">{converted_visible[0]}</div>'
@ -432,8 +435,9 @@ def generate_instruct_html(history):
f'</div>' f'</div>'
) )
selected_class = " selected-message" if message_versioning.is_message_selected(i, 1) else ""
output += ( output += (
f'<div class="assistant-message" ' f'<div class="assistant-message{selected_class}" '
f'data-raw="{html.escape(row_internal[1], quote=True)}"' f'data-raw="{html.escape(row_internal[1], quote=True)}"'
f'data-index={i}>' f'data-index={i}>'
f'<div class="text">' f'<div class="text">'
@ -466,6 +470,8 @@ def generate_cai_chat_html(history, name1, name2, style, character, reset_cache=
row_visible = history['visible'][i] row_visible = history['visible'][i]
row_internal = history['internal'][i] row_internal = history['internal'][i]
converted_visible = [convert_to_markdown_wrapped(entry, message_id=i, use_cache=i != len(history['visible']) - 1) for entry in row_visible] converted_visible = [convert_to_markdown_wrapped(entry, message_id=i, use_cache=i != len(history['visible']) - 1) for entry in row_visible]
versioning_nav_user = message_versioning.get_message_version_nav_elements(i, 0)
versioning_nav_bot = message_versioning.get_message_version_nav_elements(i, 1)
# Get timestamps # Get timestamps
user_timestamp = format_message_timestamp(history, "user", i) user_timestamp = format_message_timestamp(history, "user", i)
@ -476,8 +482,10 @@ def generate_cai_chat_html(history, name1, name2, style, character, reset_cache=
assistant_attachments = format_message_attachments(history, "assistant", i) assistant_attachments = format_message_attachments(history, "assistant", i)
if converted_visible[0]: # Don't display empty user messages if converted_visible[0]: # Don't display empty user messages
selected_class = " selected-message" if message_versioning.is_message_selected(i, 0) else ""
output += ( output += (
f'<div class="message" ' f'<div class="message{selected_class}" '
f'data-history-index="{i}" data-message-type="0" '
f'data-raw="{html.escape(row_internal[0], quote=True)}">' f'data-raw="{html.escape(row_internal[0], quote=True)}">'
f'<div class="circle-you">{img_me}</div>' f'<div class="circle-you">{img_me}</div>'
f'<div class="text">' f'<div class="text">'
@ -489,8 +497,9 @@ def generate_cai_chat_html(history, name1, name2, style, character, reset_cache=
f'</div>' f'</div>'
) )
selected_class = " selected-message" if message_versioning.is_message_selected(i, 1) else ""
output += ( output += (
f'<div class="message" ' f'<div class="message"{selected_class}'
f'data-raw="{html.escape(row_internal[1], quote=True)}"' f'data-raw="{html.escape(row_internal[1], quote=True)}"'
f'data-index={i}>' f'data-index={i}>'
f'<div class="circle-bot">{img_bot}</div>' f'<div class="circle-bot">{img_bot}</div>'
@ -514,6 +523,8 @@ def generate_chat_html(history, name1, name2, reset_cache=False):
row_visible = history['visible'][i] row_visible = history['visible'][i]
row_internal = history['internal'][i] row_internal = history['internal'][i]
converted_visible = [convert_to_markdown_wrapped(entry, message_id=i, use_cache=i != len(history['visible']) - 1) for entry in row_visible] converted_visible = [convert_to_markdown_wrapped(entry, message_id=i, use_cache=i != len(history['visible']) - 1) for entry in row_visible]
versioning_nav_user = message_versioning.get_message_version_nav_elements(i, 0)
versioning_nav_bot = message_versioning.get_message_version_nav_elements(i, 1)
# Get timestamps # Get timestamps
user_timestamp = format_message_timestamp(history, "user", i) user_timestamp = format_message_timestamp(history, "user", i)
@ -537,8 +548,10 @@ def generate_chat_html(history, name1, name2, reset_cache=False):
info_message_assistant = info_button.replace("message", assistant_timestamp_value) info_message_assistant = info_button.replace("message", assistant_timestamp_value)
if converted_visible[0]: # Don't display empty user messages if converted_visible[0]: # Don't display empty user messages
selected_class = " selected-message" if message_versioning.is_message_selected(i, 0) else ""
output += ( output += (
f'<div class="message" ' f'<div class="message{selected_class}" '
f'data-history-index="{i}"'
f'data-raw="{html.escape(row_internal[0], quote=True)}">' f'data-raw="{html.escape(row_internal[0], quote=True)}">'
f'<div class="text-you">' f'<div class="text-you">'
f'<div class="message-body">{converted_visible[0]}</div>' f'<div class="message-body">{converted_visible[0]}</div>'
@ -548,8 +561,9 @@ def generate_chat_html(history, name1, name2, reset_cache=False):
f'</div>' f'</div>'
) )
selected_class = " selected-message" if message_versioning.is_message_selected(i, 1) else ""
output += ( output += (
f'<div class="message" ' f'<div class="message{selected_class}" '
f'data-raw="{html.escape(row_internal[1], quote=True)}"' f'data-raw="{html.escape(row_internal[1], quote=True)}"'
f'data-index={i}>' f'data-index={i}>'
f'<div class="text-bot">' f'<div class="text-bot">'

View file

@ -0,0 +1,490 @@
from typing import Dict, List, Optional, Iterable
from functools import reduce
from operator import getitem
from modules import shared
from pathlib import Path
import json
import logging as logger
# --- History Storage Logic ---
# Global variables for the currently loaded history data and state
loaded_history = {'visible': [], 'internal': []}
last_state = {'character_menu': None, 'unique_id': None, 'mode': None, 'display_mode': 'html'}
def validate_list(lst: List, i: int):
"""Ensure list is properly extended to index i"""
if len(lst) <= i:
lst.extend([None] * (i + 1 - len(lst)))
def validate_history_structure(history_data: Dict, i: int):
"""Ensure history_data structure is properly extended to index i"""
for history_type in ['visible', 'internal']:
if history_type not in history_data:
history_data[history_type] = []
validate_list(history_data[history_type], i)
def init_history_entry(history_data: Dict, i: int):
"""Initialize history entry dicts at index i if nonexistent"""
for history_type in ['visible', 'internal']:
if history_data[history_type][i] is None or not isinstance(history_data[history_type][i], list):
history_data[history_type][i] = [
{'text': [], 'pos': 0}, # User message data
{'text': [], 'pos': 0} # Bot message data
]
def load_or_init_history_data(character: Optional[str], unique_id: Optional[str], mode: str) -> Dict:
"""
Loads history data from file if character and unique_id are provided, the file exists,
and the requested context (character, unique_id, mode) differs from the currently loaded context.
Otherwise, returns the existing in-memory history or a new empty history structure.
"""
global loaded_history, last_state
_character = last_state.get('character_menu')
_unique_id = last_state.get('unique_id')
_mode = last_state.get('mode')
# --- Check if requested context matches already loaded context ---
if (character == _character
and unique_id == _unique_id # noqa: W503
and mode == _mode # noqa: W503
and _character is not None): # noqa: W503
return loaded_history
# --- Context differs or history data not loaded, proceed with load/initialization ---
def _create_empty_history():
return {'visible': [], 'internal': []}
if not character or not unique_id:
logger.warning("Cannot load history data: Character or ID is missing.")
loaded_history = _create_empty_history()
return loaded_history
path = get_history_data_path(unique_id, character, mode)
if not path.exists():
loaded_history = _create_empty_history()
return loaded_history
try:
with open(path, 'r', encoding='utf-8') as f:
contents = f.read()
if contents:
loaded_data = json.loads(contents)
last_state['character_menu'] = character
last_state['unique_id'] = unique_id
last_state['mode'] = mode
loaded_history = loaded_data
return loaded_history
else:
loaded_history = _create_empty_history()
return loaded_history
except json.JSONDecodeError:
logger.error(f"Error decoding JSON from history data file: {path}. Initializing empty.", exc_info=True)
loaded_history = _create_empty_history()
return loaded_history
except Exception as e:
logger.error(f"Error loading message versioning history data from {path}: {e}", exc_info=True)
loaded_history = _create_empty_history()
return loaded_history
def append_message_version(history: Dict, state: Dict, is_bot=True):
"""
Append a message to the end of the currently loaded history data (loaded_history).
Requires state to potentially save the updated history.
"""
global loaded_history
# NOTE: Assumes the correct history for the state's character/unique_id is already loaded into loaded_history
msg_type = 1 if is_bot else 0
if not history or not history.get('visible') or not history.get('internal'):
logger.warning("Attempted to append to history data with invalid history.")
return
i = len(history['visible']) - 1
if i < 0 or len(history['visible'][i]) <= msg_type or len(history['internal'][i]) <= msg_type:
logger.warning(f"Input history index {i} or msg_type {msg_type} out of bounds for appending.")
return
visible_text = history['visible'][i][msg_type]
if not visible_text:
return
internal_text = history['internal'][i][msg_type]
try:
validate_history_structure(loaded_history, i)
init_history_entry(loaded_history, i)
# Append the strings to the respective lists in loaded_history
if 'text' not in loaded_history['visible'][i][msg_type]:
loaded_history['visible'][i][msg_type]['text'] = []
if 'text' not in loaded_history['internal'][i][msg_type]:
loaded_history['internal'][i][msg_type]['text'] = []
vis_list = loaded_history['visible'][i][msg_type]['text']
int_list = loaded_history['internal'][i][msg_type]['text']
vis_list.append(visible_text)
int_list.append(internal_text)
# Update position to the new last index
new_pos = len(vis_list) - 1
loaded_history['visible'][i][msg_type]['pos'] = new_pos
loaded_history['internal'][i][msg_type]['pos'] = new_pos
character = state['character_menu']
unique_id = state['unique_id']
mode = state['mode']
if character and unique_id:
save_history_data(character, unique_id, mode, loaded_history)
else:
logger.warning("Could not save history data after append: character or unique_id missing in state.")
except IndexError:
logger.error(f"IndexError adding message version: index={i}, msg_type={msg_type}. Message versions: {loaded_history}", exc_info=True)
except Exception as e:
logger.error(f"Error adding message version: {e}", exc_info=True)
return
def save_history_data(character: str, unique_id: str, mode: str, history_data: Dict) -> bool:
"""Save the provided history data structure to disk"""
if not unique_id or not character:
logger.warning("Cannot save history data: Character or ID is not set.")
return False
current_mode = mode or last_state.get('mode') or shared.persistent_interface_state.get('mode', 'chat')
path = get_history_data_path(unique_id, character, current_mode)
try:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
data_to_save = {'visible': history_data['visible'], 'internal': history_data['internal']}
json.dump(data_to_save, f, indent=4)
return True
except TypeError as e:
logger.error(f"TypeError saving history data (possibly non-serializable data): {e}. Data: {history_data}", exc_info=True)
except Exception as e:
logger.error(f"Error saving message versioning history data to {path}: {e}", exc_info=True)
return False
def get_history_data_path(unique_id: str, character: str, mode: str) -> Path:
"""Get the path to the history data file"""
if not unique_id or not character or not mode:
raise ValueError("unique_id, character, and mode must be provided to get history data path.")
try:
import modules.chat as chat
base_history_path = chat.get_history_file_path(unique_id, character, mode)
path = base_history_path.parent
except Exception as e:
# Fallback or error handling if get_history_file_path fails (shouldn't happen, but just in case)
logger.error(f"Failed to get base history path via get_history_file_path: {e}.", exc_info=True)
path.mkdir(parents=True, exist_ok=True)
storage_filename = f'{unique_id}.boogaplus'
final_path = path / storage_filename
return final_path
# --- Functions to be called by integrated logic ---
def set_versioning_display_mode(new_dmode: str):
"""Sets the display mode ('html', 'overlay', 'off')."""
global last_state
last_state['display_mode'] = new_dmode
def get_versioning_display_mode():
"""Gets the current display mode from the loaded history."""
current_mode = last_state.get('display_mode', 'html')
return current_mode
def clear_history_data(unique_id: str, character: str, mode: str):
"""
Deletes the history data file associated with a history.
NOTE: This function does NOT clear the in-memory loaded_history.
The caller should handle resetting/reloading the in-memory data if the deleted history was the active one.
"""
try:
history_data_path = get_history_data_path(unique_id, character, mode)
if history_data_path.exists():
history_data_path.unlink()
else:
logger.warning(f"Message versioning history data file not found for deletion: {history_data_path}")
except Exception as e:
logger.error(f"Error deleting message versioning history data file {history_data_path}: {e}", exc_info=True)
def rename_history_data(old_id: str, new_id: str, character: str, mode: str):
"""
Renames the history data file when a history is renamed.
NOTE: This function does NOT update the in-memory loaded_history if the renamed history was the active one.
The caller should handle reloading the data under the new ID if necessary.
"""
try:
old_history_data_path = get_history_data_path(old_id, character, mode)
new_history_data_path = get_history_data_path(new_id, character, mode)
if new_history_data_path.exists():
logger.warning(f"Cannot rename message versioning history data: Target path {new_history_data_path} already exists.")
return
if old_history_data_path.exists():
old_history_data_path.rename(new_history_data_path)
else:
logger.warning(f"Message versioning history data file not found for renaming: {old_history_data_path}")
except Exception as e:
logger.error(f"Error renaming message versioning history data file from {old_id} to {new_id}: {e}", exc_info=True)
# --- Core Logic ---
last_history_index = -1
last_msg_type = -1
# --- Helper Functions ---
def recursive_get(data: Dict | Iterable, keyList: List[int | str], default=None):
"""Iterate nested dictionary / iterable safely."""
try:
result = reduce(getitem, keyList, data)
return result
except (KeyError, IndexError, TypeError):
return default
except Exception as e:
logger.error(f"Error in recursive_get: {e}", exc_info=True)
return None
def length(data: Iterable):
"""Get length of iterable with try-catch."""
try:
result = len(data)
return result
except TypeError:
return 0
except Exception as e:
logger.error(f"Error getting length: {e}", exc_info=True)
return 0
# --- Core Functions ---
def get_message_positions(history_index: int, msg_type: int):
"""
Get the message position and total message versions for a specific message from the currently loaded history data.
Assumes the correct history data has already been loaded by the calling context.
"""
global loaded_history
# Path: ['visible' or 'internal'][history_index][msg_type]['pos' or 'text']
msg_data = recursive_get(loaded_history, ['visible', history_index, msg_type])
if msg_data is None:
logger.warning(f"History data not found for index {history_index}, type {msg_type}. Was history loaded correctly before calling this?")
return 0, 0
pos = recursive_get(msg_data, ['pos'], 0)
total = length(recursive_get(msg_data, ['text'], []))
return pos, total
def navigate_message_version(history_index: float, msg_type: float, direction: str, history: Dict, character: str, unique_id: str, mode: str):
"""
Handles navigation (left/right swipe) for a message version.
Updates the history dictionary directly and returns the modified history.
The calling function will be responsible for regenerating the HTML.
"""
global loaded_history, last_history_index, last_msg_type
try:
i = int(history_index)
m_type = int(msg_type)
last_history_index = i # Default to current index/type
last_msg_type = m_type
load_or_init_history_data(character, unique_id, mode)
current_pos, total_pos = get_message_positions(i, m_type)
if total_pos <= 1:
return history
# Calculate new position
new_pos = current_pos
if direction == "right":
new_pos += 1
if new_pos >= total_pos:
return history
elif direction == "left":
new_pos -= 1
if new_pos < 0:
return history
else:
logger.warning(f"Invalid navigation direction: {direction}")
return history
loaded_history_data = loaded_history
visible_msg_data = recursive_get(loaded_history_data, ['visible', i, m_type])
internal_msg_data = recursive_get(loaded_history_data, ['internal', i, m_type])
if not visible_msg_data or not internal_msg_data or 'text' not in visible_msg_data or 'text' not in internal_msg_data:
logger.error(f"Loaded history data structure invalid during navigation for index {i}, type {m_type}. Data: {loaded_history_data}")
return history
# Check bounds for the new position against the text lists
if new_pos < 0 or new_pos >= len(visible_msg_data['text']):
logger.error(f"Navigation error: new_pos {new_pos} out of bounds for visible history text list (len {len(visible_msg_data['text'])})")
return history
if new_pos < 0 or new_pos >= len(internal_msg_data['text']):
logger.error(f"Navigation error: new_pos {new_pos} out of bounds for internal history text list (len {len(internal_msg_data['text'])})")
return history
new_visible = visible_msg_data['text'][new_pos]
new_internal = internal_msg_data['text'][new_pos]
# Update the position ('pos') within the loaded history data structure
visible_msg_data['pos'] = new_pos
internal_msg_data['pos'] = new_pos
if character and unique_id:
save_history_data(character, unique_id, mode, loaded_history_data)
else:
logger.warning("Could not save history data after navigation: character or unique_id missing in state.")
# Update the main chat history dictionary in the state
current_history = history
if i < len(current_history['visible']) and m_type < len(current_history['visible'][i]):
current_history['visible'][i][m_type] = new_visible
else:
logger.error(f"History structure invalid (visible) for update at index {i}, type {m_type}")
if i < len(current_history['internal']) and m_type < len(current_history['internal'][i]):
current_history['internal'][i][m_type] = new_internal
else:
logger.error(f"History structure invalid (internal) for update at index {i}, type {m_type}")
last_history_index = i
last_msg_type = m_type
return current_history
except Exception as e:
logger.error(f"Error during message version navigation: {e}", exc_info=True)
return history
# --- Functions to be called during HTML Generation ---
def get_message_version_nav_elements(history_index: int, msg_type: int):
"""
Generates the HTML for the message version navigation arrows and position indicator.
Should be called by the HTML generator.
Returns an empty string if history storage mode is 'off' or navigation is not needed.
"""
if get_versioning_display_mode() == 'off':
return ""
try:
current_pos, total_pos = get_message_positions(history_index, msg_type)
if total_pos <= 1:
return ""
left_activated_attr = f' activated="true" data-history-index="{history_index}" data-msg-type="{msg_type}" data-direction="left"' if current_pos > 0 else ''
right_activated_attr = f' activated="true" data-history-index="{history_index}" data-msg-type="{msg_type}" data-direction="right"' if current_pos < total_pos - 1 else ''
# Similar size to copy/refresh icons
left_arrow_svg = '''<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>'''
right_arrow_svg = '''<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>'''
nav_left = f'<button class="message-versioning-nav-arrow message-versioning-nav-left footer-button"{left_activated_attr}>{left_arrow_svg}</button>'
nav_pos = f'<div class="message-versioning-nav-pos">{current_pos + 1}/{total_pos}</div>'
nav_right = f'<button class="message-versioning-nav-arrow message-versioning-nav-right footer-button"{right_activated_attr}>{right_arrow_svg}</button>'
nav_container = (
f'<div class="message-versioning-nav-container">'
f'{nav_left}'
f'{nav_pos}'
f'{nav_right}'
f'</div>'
)
versioning_container = (
f'<div class="message-versioning-container">'
f'{nav_container}'
f'</div>'
)
return versioning_container
except Exception as e:
logger.error(f"Error generating message versioning nav elements for index {history_index}, type {msg_type}: {e}", exc_info=True)
return ""
def is_message_selected(history_index: int, msg_type: int) -> bool:
"""
Returns True if the message at the specified index and type is selected.
"""
global last_history_index, last_msg_type
return last_history_index == history_index and last_msg_type == msg_type
# --- Functions to be called by Gradio Event Handlers ---
def handle_unique_id_change(history: Dict, unique_id: str, name1: str, name2: str, mode: str, chat_style: str, character: str):
"""
Handles changes to the unique_id.
Loads the corresponding message versioning history data.
"""
global last_history_index, last_msg_type
last_history_index = -1
last_msg_type = -1
if not unique_id:
logger.warning("handle_unique_id_change called with empty unique_id.")
return
import modules.chat as chat
if not character:
logger.warning(f"Cannot load history for unique_id {unique_id}: character_menu not found in state.")
# Avoid using stale data from a previous selection
load_or_init_history_data(None, unique_id, mode)
else:
load_or_init_history_data(character, unique_id, mode)
return chat.redraw_html(history, name1, name2, mode, chat_style, character)
def handle_display_mode_change(display_mode: str):
"""
Handles changes to the message versioning display mode radio button.
Updates the history storage mode. The UI redraw should be triggered by the main logic.
"""
set_versioning_display_mode(display_mode)
def handle_navigate_click(history_index: float, msg_type: float, direction: str, history: Dict, character: str, unique_id: str, mode: str, name1: str, name2: str, chat_style: str):
"""
Handles clicks on the navigation buttons generated by get_message_version_nav_elements.
Calls navigate_message_version and returns the updated history and potentially the regenerated HTML.
"""
updated_history = navigate_message_version(history_index, msg_type, direction, history, character, unique_id, mode)
import modules.chat as chat
new_html = chat.redraw_html(updated_history, name1, name2, mode, chat_style, character)
return updated_history, new_html

View file

@ -5,7 +5,7 @@ from pathlib import Path
import gradio as gr import gradio as gr
from PIL import Image from PIL import Image
from modules import chat, shared, ui, utils from modules import chat, shared, ui, utils, message_versioning
from modules.html_generator import chat_html_wrapper from modules.html_generator import chat_html_wrapper
from modules.text_generation import stop_everything_event from modules.text_generation import stop_everything_event
from modules.utils import gradio from modules.utils import gradio
@ -94,9 +94,19 @@ def create_ui():
with gr.Row(): with gr.Row():
shared.gradio['chat_style'] = gr.Dropdown(choices=utils.get_available_chat_styles(), label='Chat style', value=shared.settings['chat_style'], visible=shared.settings['mode'] != 'instruct') shared.gradio['chat_style'] = gr.Dropdown(choices=utils.get_available_chat_styles(), label='Chat style', value=shared.settings['chat_style'], visible=shared.settings['mode'] != 'instruct')
with gr.Row(visible=True): # TODO: Make visible based on mode
shared.gradio['message_versioning_display_mode'] = gr.Radio(choices=['html', 'off'], value='html', label="Message Versioning Display", info="Controls how message version navigation is displayed.", elem_id="message-versioning-display-mode", elem_classes=['slim-dropdown'])
with gr.Row(): 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']) 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 message versioning JS communication
with gr.Row(visible=False, elem_id="message-versioning-hidden-row"):
shared.gradio['message_versioning_history_index_hidden'] = gr.Number(value=0, elem_id="message-versioning-history-index-hidden")
shared.gradio['message_versioning_message_type_hidden'] = gr.Number(value=0, elem_id="message-versioning-message-type-hidden")
shared.gradio['message_versioning_direction_hidden'] = gr.Textbox(value="", elem_id="message-versioning-direction-hidden")
shared.gradio['message_versioning_navigate_hidden'] = gr.Button(elem_id="message-versioning-navigate-hidden")
def create_chat_settings_ui(): def create_chat_settings_ui():
mu = shared.args.multi_user mu = shared.args.multi_user
@ -246,6 +256,9 @@ def create_event_handlers():
shared.gradio['unique_id'].select( shared.gradio['unique_id'].select(
ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then( ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then(
chat.handle_unique_id_select, gradio('interface_state'), gradio('history', 'display'), show_progress=False) chat.handle_unique_id_select, gradio('interface_state'), gradio('history', 'display'), show_progress=False)
shared.gradio['unique_id'].change(
ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then(
message_versioning.handle_unique_id_change, gradio('history', 'unique_id', 'name1', 'name2', 'mode', 'chat_style', 'character_menu'), gradio('display'), show_progress=False)
shared.gradio['Start new chat'].click( shared.gradio['Start new chat'].click(
ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then( ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then(
@ -293,6 +306,19 @@ def create_event_handlers():
shared.gradio['chat_style'].change(chat.redraw_html, gradio(reload_arr), gradio('display'), show_progress=False) 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['Copy last reply'].click(chat.send_last_reply_to_input, gradio('history'), gradio('textbox'), show_progress=False)
# Message Versioning event handlers
shared.gradio['message_versioning_display_mode'].change(
ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then(
message_versioning.handle_display_mode_change, gradio('message_versioning_display_mode'), None).then(
chat.redraw_html, gradio(reload_arr), gradio('display'), show_progress=False)
shared.gradio['message_versioning_navigate_hidden'].click(
ui.gather_interface_values, gradio(shared.input_elements), gradio('interface_state')).then(
message_versioning.handle_navigate_click,
gradio('message_versioning_history_index_hidden', 'message_versioning_message_type_hidden', 'message_versioning_direction_hidden', 'history', 'character_menu', 'unique_id', 'mode', 'name1', 'name2', 'chat_style'),
gradio('history', 'display'),
show_progress=False)
# Save/delete a character # 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['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) shared.gradio['delete_character'].click(lambda: gr.update(visible=True), None, gradio('character_deleter'), show_progress=False)