import datetime import functools import html import os import re import time from pathlib import Path import markdown from PIL import Image, ImageOps from modules import shared from modules.reasoning import extract_reasoning from modules.sane_markdown_lists import SaneListExtension from modules.utils import get_available_chat_styles # This is to store the paths to the thumbnails of the profile pictures image_cache = {} def minify_css(css: str) -> str: # Step 1: Remove comments css = re.sub(r'/\*.*?\*/', '', css, flags=re.DOTALL) # Step 2: Remove leading and trailing whitespace css = re.sub(r'^[ \t]*|[ \t]*$', '', css, flags=re.MULTILINE) # Step 3: Remove spaces after specific characters ({ : ; ,}) css = re.sub(r'([:{;,])\s+', r'\1', css) # Step 4: Remove spaces before `{` css = re.sub(r'\s+{', '{', css) # Step 5: Remove empty lines css = re.sub(r'^\s*$', '', css, flags=re.MULTILINE) # Step 6: Collapse all lines into one css = re.sub(r'\n', '', css) return css with open(Path(__file__).resolve().parent / '../css/html_readable_style.css', 'r', encoding='utf-8') as f: readable_css = f.read() with open(Path(__file__).resolve().parent / '../css/html_instruct_style.css', 'r', encoding='utf-8') as f: instruct_css = f.read() # Custom chat styles chat_styles = {} for k in get_available_chat_styles(): with open(Path(f'css/chat_style-{k}.css'), 'r', encoding='utf-8') as f: chat_styles[k] = f.read() # Handle styles that derive from other styles for k in chat_styles: lines = chat_styles[k].split('\n') input_string = lines[0] match = re.search(r'chat_style-([a-z\-]*)\.css', input_string) if match: style = match.group(1) chat_styles[k] = chat_styles.get(style, '') + '\n\n' + '\n'.join(lines[1:]) # Reduce the size of the CSS sources above readable_css = minify_css(readable_css) instruct_css = minify_css(instruct_css) for k in chat_styles: chat_styles[k] = minify_css(chat_styles[k]) def fix_newlines(string): string = string.replace('\n', '\n\n') string = re.sub(r"\n{3,}", "\n\n", string) string = string.strip() return string def replace_quotes(text): # Define a list of quote pairs (opening and closing), using HTML entities quote_pairs = [ ('"', '"'), # Double quotes ('“', '”'), # Unicode left and right double quotation marks ('‘', '’'), # Unicode left and right single quotation marks ('«', '»'), # French quotes ('„', '“'), # German quotes ('‘', '’'), # Alternative single quotes ('“', '”'), # Unicode quotes (numeric entities) ('“', '”'), # Unicode quotes (hex entities) ('\u201C', '\u201D'), # Unicode quotes (literal chars) ] # Create a regex pattern that matches any of the quote pairs, including newlines pattern = '|'.join(f'({re.escape(open_q)})(.*?)({re.escape(close_q)})' for open_q, close_q in quote_pairs) # Replace matched patterns with tags, keeping original quotes def replacer(m): # Find the first non-None group set for i in range(1, len(m.groups()), 3): # Step through each sub-pattern's groups if m.group(i): # If this sub-pattern matched return f'{m.group(i)}{m.group(i + 1)}{m.group(i + 2)}' return m.group(0) # Fallback (shouldn't happen) replaced_text = re.sub(pattern, replacer, text, flags=re.DOTALL) return replaced_text def replace_blockquote(m): return m.group().replace('\n', '\n> ').replace('\\begin{blockquote}', '').replace('\\end{blockquote}', '') def extract_thinking_block(string): """Extract thinking blocks from the beginning of an HTML-escaped string.""" return extract_reasoning(string, html_escaped=True) def build_tool_call_block(header, body, message_id, index): """Build HTML for a tool call accordion block.""" block_id = f"tool-call-{message_id}-{index}" if body == '...': # Pending placeholder — no expandable body, just title with ellipsis return f'''
{tool_svg_small} {html.escape(header)} ...
''' # Build a plain
 directly to avoid highlight.js auto-detection
    escaped_body = html.escape(body)
    return f'''
    
{tool_svg_small} {html.escape(header)}
{escaped_body}
''' def build_thinking_block(thinking_content, message_id, has_remaining_content, thinking_index=0): """Build HTML for a thinking block.""" if thinking_content is None: return None # Process the thinking content through markdown thinking_html = process_markdown_content(thinking_content) # Generate unique ID for the thinking block block_id = f"thinking-{message_id}-{thinking_index}" # Check if thinking is complete or still in progress is_streaming = not has_remaining_content title_text = "Thinking..." if is_streaming else "Thought" return f'''
{info_svg_small} {title_text}
{thinking_html}
''' def build_main_content_block(content): """Build HTML for the main content block.""" if not content: return "" return process_markdown_content(content) def process_markdown_content(string): """ Process a string through the markdown conversion pipeline. Uses robust manual parsing to ensure correct LaTeX and Code Block rendering. """ if not string: return "" # Define unique placeholders for LaTeX asterisks and underscores LATEX_ASTERISK_PLACEHOLDER = "LATEXASTERISKPLACEHOLDER" LATEX_UNDERSCORE_PLACEHOLDER = "LATEXUNDERSCOREPLACEHOLDER" def protect_asterisks_underscores_in_latex(match): """A replacer function for re.sub to protect asterisks and underscores in multiple LaTeX formats.""" # Check which delimiter group was captured if match.group(1) is not None: # Content from $$...$$ content = match.group(1) modified_content = content.replace('*', LATEX_ASTERISK_PLACEHOLDER) modified_content = modified_content.replace('_', LATEX_UNDERSCORE_PLACEHOLDER) return f'{modified_content}' elif match.group(2) is not None: # Content from \[...\] content = match.group(2) modified_content = content.replace('*', LATEX_ASTERISK_PLACEHOLDER) modified_content = modified_content.replace('_', LATEX_UNDERSCORE_PLACEHOLDER) return f'\\[{modified_content}\\]' elif match.group(3) is not None: # Content from \(...\) content = match.group(3) modified_content = content.replace('*', LATEX_ASTERISK_PLACEHOLDER) modified_content = modified_content.replace('_', LATEX_UNDERSCORE_PLACEHOLDER) return f'\\({modified_content}\\)' return match.group(0) # Fallback # Make \[ \] LaTeX equations inline pattern = r'^\s*\\\[\s*\n([\s\S]*?)\n\s*\\\]\s*$' replacement = r'\\[ \1 \\]' string = re.sub(pattern, replacement, string, flags=re.MULTILINE) # Escape backslashes string = string.replace('\\', '\\\\') # Quote to string = replace_quotes(string) # Blockquote string = re.sub(r'(^|[\n])>', r'\1>', string) pattern = re.compile(r'\\begin{blockquote}(.*?)\\end{blockquote}', re.DOTALL) string = pattern.sub(replace_blockquote, string) # Code block standardization string = string.replace('\\begin{code}', '```') string = string.replace('\\end{code}', '```') string = string.replace('\\begin{align*}', '$$') string = string.replace('\\end{align*}', '$$') string = string.replace('\\begin{align}', '$$') string = string.replace('\\end{align}', '$$') string = string.replace('\\begin{equation}', '$$') string = string.replace('\\end{equation}', '$$') string = string.replace('\\begin{equation*}', '$$') string = string.replace('\\end{equation*}', '$$') string = re.sub(r"(.)```", r"\1\n```", string) # Protect asterisks and underscores within all LaTeX blocks before markdown conversion latex_pattern = re.compile(r'((?:^|[\r\n\s])\$\$[^`]*?\$\$)|\\\[(.*?)\\\]|\\\((.*?)\\\)', re.DOTALL) string = latex_pattern.sub(protect_asterisks_underscores_in_latex, string) result = '' is_code = False is_latex = False # Manual line iteration for robust structure parsing for line in string.split('\n'): stripped_line = line.strip() if stripped_line.startswith('```'): is_code = not is_code elif stripped_line.startswith('$$') and (stripped_line == "$$" or not stripped_line.endswith('$$')): is_latex = not is_latex elif stripped_line.endswith('$$'): is_latex = False elif stripped_line.startswith('\\\\[') and not stripped_line.endswith('\\\\]'): is_latex = True elif stripped_line.startswith('\\\\]'): is_latex = False elif stripped_line.endswith('\\\\]'): is_latex = False result += line # Don't add an extra \n for code, LaTeX, or tables if is_code or is_latex or line.startswith('|'): result += '\n' # Also don't add an extra \n for lists elif stripped_line.startswith('-') or stripped_line.startswith('*') or stripped_line.startswith('+') or stripped_line.startswith('>') or re.match(r'\d+\.', stripped_line): result += ' \n' else: result += ' \n' result = result.strip() if is_code: result += '\n```' # Unfinished code block # Unfinished list, like "\n1.". A |delete| string is added and then # removed to force a
    or
      to be generated instead of a

      . list_item_pattern = r'(\n\d+\.?|\n\s*[-*+]\s*([*_~]{1,3})?)$' if re.search(list_item_pattern, result): delete_str = '|delete|' if re.search(r'(\d+\.?)$', result) and not result.endswith('.'): result += '.' # Add the delete string after the list item result = re.sub(list_item_pattern, r'\g<1> ' + delete_str, result) # Convert to HTML using markdown html_output = markdown.markdown(result, extensions=['fenced_code', 'tables', SaneListExtension()]) # Remove the delete string from the HTML output pos = html_output.rfind(delete_str) if pos > -1: html_output = html_output[:pos] + html_output[pos + len(delete_str):] else: # Convert to HTML using markdown html_output = markdown.markdown(result, extensions=['fenced_code', 'tables', SaneListExtension()]) # Restore the LaTeX asterisks and underscores after markdown conversion html_output = html_output.replace(LATEX_ASTERISK_PLACEHOLDER, '*') html_output = html_output.replace(LATEX_UNDERSCORE_PLACEHOLDER, '_') # Remove extra newlines before html_output = re.sub(r'\s*', '', html_output) # Unescape code blocks pattern = re.compile(r']*>(.*?)', re.DOTALL) html_output = pattern.sub(lambda x: html.unescape(x.group()), html_output) # Unescape backslashes html_output = html_output.replace('\\\\', '\\') # Wrap tables in a scrollable div html_output = html_output.replace('', '
      ').replace('
      ', '') return html_output @functools.lru_cache(maxsize=None) def convert_to_markdown(string, message_id=None): """ Convert a string to markdown HTML with support for multiple block types. Blocks are assembled in order: thinking, main content, etc. """ if not string: return "" # Use a default message ID if none provided if message_id is None: message_id = "unknown" # Find tool call blocks by position, then process the text segments # between them using extract_thinking_block (which supports all # THINKING_FORMATS, including end-only variants like Qwen's). tool_call_pattern = re.compile(r'(.*?)\n(.*?)\n', re.DOTALL) tool_calls = list(tool_call_pattern.finditer(string)) if not tool_calls: # No tool calls — use original single-pass extraction thinking_content, remaining_content = extract_thinking_block(string) blocks = [] thinking_html = build_thinking_block(thinking_content, message_id, bool(remaining_content)) if thinking_html: blocks.append(thinking_html) main_html = build_main_content_block(remaining_content) if main_html: blocks.append(main_html) return ''.join(blocks) # Split string into text segments around tool_call blocks and # run extract_thinking_block on each segment for full format support. html_parts = [] last_end = 0 tool_idx = 0 think_idx = 0 def process_text_segment(text, is_last_segment): """Process a text segment between tool_call blocks for thinking content.""" nonlocal think_idx if not text.strip(): return while text.strip(): thinking_content, remaining = extract_thinking_block(text) if thinking_content is None: break has_remaining = bool(remaining.strip()) or not is_last_segment html_parts.append(build_thinking_block(thinking_content, message_id, has_remaining, think_idx)) think_idx += 1 text = remaining if text.strip(): html_parts.append(process_markdown_content(text)) for tc in tool_calls: # Process text before this tool_call process_text_segment(string[last_end:tc.start()], is_last_segment=False) # Add tool call accordion header = tc.group(1).strip() body = tc.group(2).strip() html_parts.append(build_tool_call_block(header, body, message_id, tool_idx)) tool_idx += 1 last_end = tc.end() # Process text after the last tool_call process_text_segment(string[last_end:], is_last_segment=True) return ''.join(html_parts) def convert_to_markdown_wrapped(string, message_id=None, use_cache=True): ''' Used to avoid caching convert_to_markdown calls during streaming. ''' if use_cache: return convert_to_markdown(string, message_id=message_id) return convert_to_markdown.__wrapped__(string, message_id=message_id) def generate_basic_html(string): convert_to_markdown.cache_clear() string = convert_to_markdown(string) string = f'

      {string}
      ' return string def make_thumbnail(image): image = image.resize((350, round(image.size[1] / image.size[0] * 350)), Image.Resampling.LANCZOS) if image.size[1] > 470: image = ImageOps.fit(image, (350, 470), Image.LANCZOS) return image def get_image_cache(path): cache_folder = Path(shared.args.disk_cache_dir) if not cache_folder.exists(): cache_folder.mkdir() mtime = os.stat(path).st_mtime if (path in image_cache and mtime != image_cache[path][0]) or (path not in image_cache): img = make_thumbnail(Image.open(path)) old_p = Path(f'{cache_folder}/{path.name}_cache.png') p = Path(f'{cache_folder}/cache_{path.name}.png') if old_p.exists(): old_p.rename(p) output_file = p img.convert('RGBA').save(output_file, format='PNG') image_cache[path] = [mtime, output_file.as_posix()] return image_cache[path][1] copy_svg = '''''' refresh_svg = '''''' continue_svg = '''''' remove_svg = '''''' branch_svg = '''''' edit_svg = '''''' info_svg = '''''' info_svg_small = '''''' tool_svg_small = '''''' attachment_svg = '''''' copy_button = f'' branch_button = f'' edit_button = f'' refresh_button = f'' continue_button = f'' remove_button = f'' info_button = f'' def format_message_timestamp(history, role, index, tooltip_include_timestamp=True): """Get a formatted timestamp HTML span for a message if available""" key = f"{role}_{index}" if 'metadata' in history and key in history['metadata'] and history['metadata'][key].get('timestamp'): timestamp = history['metadata'][key]['timestamp'] tooltip_text = get_message_tooltip(history, role, index, include_timestamp=tooltip_include_timestamp) title_attr = f' title="{html.escape(tooltip_text)}"' if tooltip_text else '' return f"{timestamp}" return "" def format_message_attachments(history, role, index): """Get formatted HTML for message attachments if available""" key = f"{role}_{index}" if 'metadata' in history and key in history['metadata'] and 'attachments' in history['metadata'][key]: attachments = history['metadata'][key]['attachments'] if not attachments: return "" attachments_html = '
      ' for attachment in attachments: name = html.escape(attachment["name"]) if attachment.get("type") == "image": image_data = attachment.get("image_data", "") attachments_html += ( f'
      ' f'{name}' f'
      {name}
      ' f'
      ' ) else: # Make clickable if URL exists (web search) if "url" in attachment: name = f'{name}' attachments_html += ( f'
      ' f'
      {attachment_svg}
      ' f'
      {name}
      ' f'
      ' ) attachments_html += '
      ' return attachments_html return "" def get_message_tooltip(history, role, index, include_timestamp=True): """Get tooltip text combining timestamp and model name for a message""" key = f"{role}_{index}" if 'metadata' not in history or key not in history['metadata']: return "" meta = history['metadata'][key] tooltip_parts = [] if include_timestamp and meta.get('timestamp'): tooltip_parts.append(meta['timestamp']) if meta.get('model_name'): tooltip_parts.append(f"Model: {meta['model_name']}") return " | ".join(tooltip_parts) def get_version_navigation_html(history, i, role): """Generate simple navigation arrows for message versions""" key = f"{role}_{i}" metadata = history.get('metadata', {}) if key not in metadata or 'versions' not in metadata[key]: return "" versions = metadata[key]['versions'] # Default to the last version if current_version_index isn't set in metadata current_idx = metadata[key].get('current_version_index', len(versions) - 1 if versions else 0) if len(versions) <= 1: return "" left_disabled = ' disabled' if current_idx == 0 else '' right_disabled = ' disabled' if current_idx >= len(versions) - 1 else '' left_arrow = f'' right_arrow = f'' position = f'{current_idx + 1}/{len(versions)}' return f'
      {left_arrow}{position}{right_arrow}
      ' def actions_html(history, i, role, info_message=""): action_buttons = "" version_nav_html = "" if role == "assistant": action_buttons = ( 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}' ) version_nav_html = get_version_navigation_html(history, i, "assistant") elif role == "user": action_buttons = ( f'{copy_button}' f'{edit_button}' ) version_nav_html = get_version_navigation_html(history, i, "user") return (f'
      ' f'{action_buttons}' f'{info_message}' f'
      ' f'{version_nav_html}') def generate_instruct_html(history, last_message_only=False): if not last_message_only: output = f'
      ' else: output = "" def create_message(role, content, raw_content): """Inner function that captures variables from outer scope.""" class_name = "user-message" if role == "user" else "assistant-message" # Get role-specific data timestamp = format_message_timestamp(history, role, i) attachments = format_message_attachments(history, role, i) # Create info button if timestamp exists info_message = "" if timestamp: tooltip_text = get_message_tooltip(history, role, i) info_message = info_button.replace('title="message"', f'title="{html.escape(tooltip_text)}"') return ( f'
      ' f'
      ' f'
      {content}
      ' f'{attachments}' f'{actions_html(history, i, role, info_message)}' f'
      ' f'
      ' ) # Determine range start_idx = len(history['visible']) - 1 if last_message_only else 0 end_idx = len(history['visible']) for i in range(start_idx, end_idx): row_visible = history['visible'][i] row_internal = history['internal'][i] # Convert content if last_message_only: converted_visible = [None, convert_to_markdown_wrapped(row_visible[1], message_id=i, use_cache=i != len(history['visible']) - 1)] else: converted_visible = [convert_to_markdown_wrapped(entry, message_id=i, use_cache=i != len(history['visible']) - 1) for entry in row_visible] # Generate messages if not last_message_only and converted_visible[0]: output += create_message("user", converted_visible[0], row_internal[0]) output += create_message("assistant", converted_visible[1], row_internal[1]) if not last_message_only: output += "
      " return output def get_character_image_with_cache_buster(): """Get character image URL with cache busting based on file modification time""" cache_path = shared.user_data_dir / "cache" / "pfp_character_thumb.png" if cache_path.exists(): mtime = int(cache_path.stat().st_mtime) return f'' return '' def generate_cai_chat_html(history, name1, name2, style, character, reset_cache=False, last_message_only=False): if not last_message_only: output = f'
      ' else: output = "" img_bot = get_character_image_with_cache_buster() def create_message(role, content, raw_content): """Inner function for CAI-style messages.""" circle_class = "circle-you" if role == "user" else "circle-bot" name = name1 if role == "user" else name2 # Get role-specific data timestamp = format_message_timestamp(history, role, i, tooltip_include_timestamp=False) attachments = format_message_attachments(history, role, i) # Get appropriate image if role == "user": img = (f'' if (shared.user_data_dir / "cache" / "pfp_me.png").exists() else '') else: img = img_bot return ( f'
      ' f'
      {img}
      ' f'
      ' f'
      {name}{timestamp}
      ' f'
      {content}
      ' f'{attachments}' f'{actions_html(history, i, role)}' f'
      ' f'
      ' ) # Determine range start_idx = len(history['visible']) - 1 if last_message_only else 0 end_idx = len(history['visible']) for i in range(start_idx, end_idx): row_visible = history['visible'][i] row_internal = history['internal'][i] # Convert content if last_message_only: converted_visible = [None, convert_to_markdown_wrapped(row_visible[1], message_id=i, use_cache=i != len(history['visible']) - 1)] else: converted_visible = [convert_to_markdown_wrapped(entry, message_id=i, use_cache=i != len(history['visible']) - 1) for entry in row_visible] # Generate messages if not last_message_only and converted_visible[0]: output += create_message("user", converted_visible[0], row_internal[0]) output += create_message("assistant", converted_visible[1], row_internal[1]) if not last_message_only: output += "
      " return output def time_greeting(): current_hour = datetime.datetime.now().hour if 5 <= current_hour < 12: return "Good morning!" elif 12 <= current_hour < 18: return "Good afternoon!" else: return "Good evening!" def chat_html_wrapper(history, name1, name2, mode, style, character, reset_cache=False, last_message_only=False): if len(history['visible']) == 0: greeting = f"
      {time_greeting()} How can I help you today?
      " result = f'
      {greeting}
      ' elif mode == 'instruct': result = generate_instruct_html(history, last_message_only=last_message_only) else: result = generate_cai_chat_html(history, name1, name2, style, character, reset_cache=reset_cache, last_message_only=last_message_only) return {'html': result, 'last_message_only': last_message_only}