JanitorAI - Dynamic Character Avatars

Logs the last bot message and shows an emotion-based avatar. Completly customizable.

// ==UserScript==
// @name         JanitorAI - Dynamic Character Avatars
// @namespace    http://tampermonkey.net/
// @version      1.7
// @license      MIT
// @author       Zephyr (@xzeph__)
// @description  Logs the last bot message and shows an emotion-based avatar. Completly customizable.
// @match        https://janitorai.com/chats/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=janitorai.com
// @grant        GM_log
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      generativelanguage.googleapis.com
// ==/UserScript==

/* 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。
 *                                                                                   *
 *                    JanitorAI - Dynamic Character Avatars Script                   *
 *                                                                                   *
 *   This script adds dynamic, emotion-based avatars to JanitorAI chats!             *
 *   It analyzes the bot's last message and shows a cute avatar matching             *
 *   the detected emotion.                                                           *
 *                                                                                   *
 *      HOW TO CUSTOMIZE                                                             *
 *   ─────────────────────                                                           *
 *   • SECTION 1: Change/add/remove your character avatars and emotion images.       *
 *   • SECTION 2: Set your Gemini API key and model.                                 *
 *   • SECTION 5: Edit the prompt sent to Gemini for emotion analysis.               *
 *     (Make sure the emotion list here matches your avatars in Section 1!)          *
 *                                                                                   *
 *   All other sections are core logic and style.                                    *
 *   For best results, only edit Sections 1, 2, and 5!                               *
 *                                                                                   *
 *                                                                                   *
 * 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。 。・゚゚・。
*/

(function () {
  "use strict";

  /*
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  *                                                                           *
  *                  SECTION 1: AVATAR CONFIGURATION                          *
  *           (Customize your character emotion avatars here!)                *
  *                                                                           *
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  */

  // 🖼️ --- Character Emotion Avatars --- 🖼️
  const characterEmotionAvatars = {
    Ashley: {
      1: "https://i.ibb.co/BHNGbwC2/proud.webp", // Admiration
      2: "https://i.ibb.co/XZFRjgWX/amusement.webp", // Amusement
      3: "https://i.ibb.co/qYQvPc7g/yelling-insulting-angry-alittlelessintense-arguingagressive.webp", // Anger
      4: "https://i.ibb.co/mV297s2V/irritated-annoyed.webp", // Annoyance
      5: "https://i.ibb.co/tpRmY6Nj/chatty-hasalittlesmilewhiletalking.webp", // Approval
      6: "https://i.ibb.co/tMztWz5L/gentle.webp", // Caring
      7: "https://i.ibb.co/39YzgMdV/embarrassment.webp", // Confusion
      8: "https://files.catbox.moe/m99dz8.png", // Curiosity
      9: "https://i.ibb.co/4gMkVBmb/teasing-mockingaffection.webp", // Desire
      10: "https://i.ibb.co/mV297s2V/irritated-annoyed.webp", // Dissapointment
      11: "https://i.ibb.co/j981cRP2/exasperation.webp", // Disapproval
      12: "https://i.ibb.co/HTZgC02b/disgusted.webp", // Disgust
      13: "https://i.ibb.co/39YzgMdV/embarrassment.webp", // Embarrassment
      14: "https://i.ibb.co/6J8WYw9T/excited.webp", // Excitement
      15: "https://i.ibb.co/FL2HgSRY/terrified.webp", // Fear
      16: "https://i.ibb.co/tpRmY6Nj/chatty-hasalittlesmilewhiletalking.webp", // Gratitude
      17: "https://i.ibb.co/Xx94PTxr/smiling.webp", // Joy
      18: "https://i.ibb.co/tMztWz5L/gentle.webp", // Love
      19: "https://i.ibb.co/YFtm7tvZ/nervouslaugh.webp", // Nervousness
      20: "https://i.ibb.co/DDSmJHRC/bored-dismissive.webp", // Neutral
      21: "https://i.ibb.co/6J8WYw9T/excited.webp", // Optimism
      22: "https://i.ibb.co/BHNGbwC2/proud.webp", // Pride
      23: "https://i.ibb.co/N6VxqxVy/surprised-confused.webp", // Realization
      24: "https://i.ibb.co/k27tvrfK/resignation.webp", // Relief
      25: "https://i.ibb.co/JR6S8LNC/weak-unwell-depressed.webp", // Remorse
      26: "https://i.ibb.co/tPcnCf41/sad.webp", // Sadness
      27: "https://i.ibb.co/NghfF9vc/greatlysurprised-revelation.webp", // Surprise
    },

    // Add other characters and emotions here if needed. The default character is Ashley
  };

  /*
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  *                                                                           *
  *                  SECTION 2: GEMINI API CONFIGURATION                      *
  *           (Set up your Gemini API key and model here!)                    *
  *                                                                           *
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  */

  // 🔑 --- API Key & Model --- 🔑
  const apiKey = "YOUR_OWN_API_KEY"; // Replace with your actual API key
  const model = "gemini-2.0-flash";
  const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;

  /*
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  *                                                                           *
  *                  SECTION 3: AVATAR STYLES & FUNCTIONS                     *
  *           (Handles avatar CSS, creation, and interaction!)                *
  *                                                                           *
  *   ⚠️ WARNING: Do not modify the rest of the code unless you know what      *
  *   you're doing! This section and below contains core logic and utilities.  *
  *   To customize how emotions are detected, go to SECTION 5!                *
  *                                                                           *
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  */

  // 🎨 --- Avatar CSS Styles --- 🎨
  GM_addStyle(`
    .emotion-avatar-item {
      position: relative;
      display: flex;
      flex-direction: column;
      align-items: center; /* Center children horizontally */
      margin-bottom: 5px;
    }
    img.emotion-avatar-float {
      transition: opacity 0.3s ease;
      cursor: move;
      width: 100px;
      height: auto;
      margin-bottom: 8px;
      display: block;
    }
    .emotion-avatar-controls {
      position: absolute;
      top: 0;
      right: -50px;
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 5px;
      opacity: 0;
      z-index: 10;
      transition: opacity 0.3s ease;
    }
    .emotion-avatar-controls button {
      border-radius: 50%;
      background: linear-gradient(to bottom,
        rgba(176, 196, 222, 0.8),
        rgba(255, 255, 255, 0.5));
      color: rgba(255, 255, 255, 0.9) !important;
      box-shadow: 0 0 5px rgba(176, 196, 222, 0.8);
      transition: box-shadow 0.3s ease-in-out;
      border: none;
      width: 40px;
      height: 40px;
      padding: 0;
      margin: 2px 0;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .emotion-avatar-controls button:hover {
      box-shadow: 0 0 10px rgba(176, 196, 222, 1);
    }
    .emotion-avatar-slider-fade {
      opacity: 0;
      transition: opacity 0.3s ease;
      width: 100%;
      margin-top: 0;
      margin-bottom: 4px;
      left: 0;
      position: relative;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    /* --- GLASSY SLIDER THEME --- */
    .size-slider {
      -webkit-appearance: none;
      appearance: none;
      width: 100px;
      height: 8px;
      border-radius: 8px;
      background: linear-gradient(90deg, rgba(176,196,222,0.7) 0%, rgba(255,255,255,0.5) 100%);
      box-shadow: 0 2px 8px 0 rgba(176,196,222,0.25), 0 1.5px 0px 0px rgba(255,255,255,0.25) inset;
      outline: none;
      opacity: 0.95;
      transition: box-shadow 0.2s;
      cursor: pointer;
      border: 1px solid rgba(176,196,222,0.3);
      backdrop-filter: blur(2px);
      display: block;
      margin-top: 3px;
      margin-left: auto;
      margin-right: auto;
    }
    .size-slider:focus {
      box-shadow: 0 0 0 2px rgba(176,196,222,0.5);
    }
    .size-slider::-webkit-slider-thumb {
      -webkit-appearance: none;
      appearance: none;
      width: 18px;
      height: 18px;
      border-radius: 50%;
      background: linear-gradient(135deg, rgba(255,255,255,0.85) 60%, rgba(176,196,222,0.7) 100%);
      box-shadow: 0 0 8px 2px rgba(176,196,222,0.7), 0 2px 6px 0 rgba(176,196,222,0.3);
      border: 2px solid rgba(255,255,255,0.7);
      cursor: pointer;
      transition: box-shadow 0.2s;
      filter: drop-shadow(0 0 4px rgba(176,196,222,0.5));
    }
    .size-slider:active::-webkit-slider-thumb {
      box-shadow: 0 0 12px 4px rgba(176,196,222,0.9);
    }
    .size-slider::-moz-range-thumb {
      width: 18px;
      height: 18px;
      border-radius: 50%;
      background: linear-gradient(135deg, rgba(255,255,255,0.85) 60%, rgba(176,196,222,0.7) 100%);
      box-shadow: 0 0 8px 2px rgba(176,196,222,0.7), 0 2px 6px 0 rgba(176,196,222,0.3);
      border: 2px solid rgba(255,255,255,0.7);
      cursor: pointer;
      transition: box-shadow 0.2s;
      filter: drop-shadow(0 0 4px rgba(176,196,222,0.5));
    }
    .size-slider:active::-moz-range-thumb {
      box-shadow: 0 0 12px 4px rgba(176,196,222,0.9);
    }
    .size-slider::-ms-thumb {
      width: 18px;
      height: 18px;
      border-radius: 50%;
      background: linear-gradient(135deg, rgba(255,255,255,0.85) 60%, rgba(176,196,222,0.7) 100%);
      box-shadow: 0 0 8px 2px rgba(176,196,222,0.7), 0 2px 6px 0 rgba(176,196,222,0.3);
      border: 2px solid rgba(255,255,255,0.7);
      cursor: pointer;
      transition: box-shadow 0.2s;
      filter: drop-shadow(0 0 4px rgba(176,196,222,0.5));
    }
    .size-slider:active::-ms-thumb {
      box-shadow: 0 0 12px 4px rgba(176,196,222,0.9);
    }
    .size-slider::-ms-fill-lower {
      background: transparent;
    }
    .size-slider::-ms-fill-upper {
      background: transparent;
    }
    .size-slider:focus {
      outline: none;
    }
    .size-slider::-webkit-slider-runnable-track {
      height: 8px;
      border-radius: 8px;
      background: transparent;
    }
    .size-slider::-moz-range-track {
      height: 8px;
      border-radius: 8px;
      background: transparent;
    }
    .size-slider::-ms-fill-lower,
    .size-slider::-ms-fill-upper {
      background: transparent;
    }
    .slider-value-popup {
      position: absolute;
      bottom: 15px;
      left: 50%;
      transform: translateX(-50%);
      background-color: rgba(0, 0, 0, 0.7);
      color: white;
      padding: 2px 5px;
      border-radius: 3px;
      font-size: 12px;
      white-space: nowrap;
      display: none;
    }
  `);

  let botAvatars = {};
  let botCount = 0;
  let scriptDisabled = false;

  // 🧩 --- Avatar & Gemini Functions --- 🧩
  function addEmotionAvatar(emotionNumber, characterName) {
    if (scriptDisabled) return;
    if (!botAvatars[characterName]) {
      const avatarItem = document.createElement("div");
      avatarItem.classList.add("emotion-avatar-item");
      avatarItem.style.position = "fixed";
      avatarItem.style.top = `${50 + botCount * 20}px`;
      avatarItem.style.left = `${20 + botCount * 20}px`;
      avatarItem.style.zIndex = 1000 + botCount;

      const avatar = document.createElement("img");
      avatar.classList.add("emotion-avatar-float");
      avatar.style.opacity = "1";

      // Limit avatar size after image loads
      avatar.onload = () => {
        getMaxAvatarSize(avatar, ({ width, height }) => {
          avatar.style.width = Math.min(100, width) + "px";
          avatar.style.maxWidth = width + "px";
          avatar.style.maxHeight = height + "px";
          if (avatar._sizeSlider) {
            avatar._sizeSlider.max = width;
          }
        });
      };

      // --- Size Slider ---
      const sliderContainer = document.createElement("div");
      sliderContainer.className = "size-slider-container emotion-avatar-slider-fade";
      const sizeSlider = document.createElement("input");
      sizeSlider.type = "range";
      sizeSlider.min = "80";
      sizeSlider.max = "800";
      sizeSlider.value = "100";
      sizeSlider.className = "size-slider";
      sizeSlider.style.width = "100px";
      avatar._sizeSlider = sizeSlider;

      // Limit slider max after image loads
      getMaxAvatarSize(avatar, ({ width }) => {
        sizeSlider.max = width;
        if (parseInt(sizeSlider.value) > width) {
          sizeSlider.value = width;
          avatar.style.width = width + "px";
          sizeSlider.style.width = Math.max(50, width * 0.8) + "px";
        }
      });

      const valuePopup = document.createElement("div");
      valuePopup.className = "slider-value-popup";
      sliderContainer.appendChild(valuePopup);

      const updatePopup = () => {
        valuePopup.textContent = `${sizeSlider.value}px`;
        const percent = (sizeSlider.value - sizeSlider.min) / (sizeSlider.max - sizeSlider.min);
        const thumbWidth = 12;
        const popupLeft = percent * (sizeSlider.offsetWidth - thumbWidth) + thumbWidth / 2;
        valuePopup.style.left = `${popupLeft}px`;
      };

      const handleSliderInteractionStart = (e) => {
          e.stopPropagation();
          valuePopup.style.display = 'block';
          updatePopup();
      };

      const handleSliderInteractionEnd = () => {
          valuePopup.style.display = 'none';
      };

      sizeSlider.addEventListener("mousedown", handleSliderInteractionStart);
      sizeSlider.addEventListener("touchstart", handleSliderInteractionStart);

      sizeSlider.addEventListener("input", updatePopup);

      sizeSlider.addEventListener("mouseup", handleSliderInteractionEnd);
      sizeSlider.addEventListener("touchend", handleSliderInteractionEnd);

      sizeSlider.addEventListener("change", () => {
        // Clamp to max allowed size
        const maxAllowed = parseInt(sizeSlider.max);
        let newWidth = Math.min(parseInt(sizeSlider.value), maxAllowed);
        avatar.style.width = newWidth + "px";
        sizeSlider.style.width = Math.max(50, newWidth * 0.8) + "px";
        keepInBounds(avatarItem);
      });

      sliderContainer.appendChild(sizeSlider);

      const controls = addControlButtons(avatarItem, avatar, sliderContainer);

      avatarItem.appendChild(avatar);
      avatarItem.appendChild(sliderContainer);
      avatarItem.appendChild(controls);
      document.body.appendChild(avatarItem);

      botAvatars[characterName] = { avatar, container: avatarItem };
      makeDraggable(avatarItem);
      botCount++;
    }

    const avatarData = botAvatars[characterName];
    if (!avatarData) return;

    const characterAvatars = characterEmotionAvatars[characterName] || characterEmotionAvatars["Ashley"];
    const newSrc = characterAvatars[emotionNumber];

    if (newSrc && avatarData.avatar.src !== newSrc) {
      avatarData.avatar.style.opacity = "0";
      setTimeout(() => {
        avatarData.avatar.src = newSrc;
        avatarData.avatar.onload = () => {
          avatarData.avatar.style.opacity = "1";
          // Re-limit size on new image
          getMaxAvatarSize(avatarData.avatar, ({ width, height }) => {
            avatarData.avatar.style.maxWidth = width + "px";
            avatarData.avatar.style.maxHeight = height + "px";
            if (avatarData.avatar._sizeSlider) {
              avatarData.avatar._sizeSlider.max = width;
              // Clamp current size if needed
              if (parseInt(avatarData.avatar.style.width) > width) {
                avatarData.avatar.style.width = width + "px";
                avatarData.avatar._sizeSlider.value = width;
              }
            }
          });
        };
      }, 300);
    } else if (!newSrc) {
      console.error(`Emotion number ${emotionNumber} not found for character: ${characterName}`);
    }
  }

  // 🛡️ --- Keep Avatar In Bounds --- 🛡️
  function keepInBounds(elmnt) {
    if (scriptDisabled) return;
    const rect = elmnt.getBoundingClientRect();
    const winWidth = window.innerWidth;
    const winHeight = window.innerHeight;

    let newLeft = rect.left;
    let newTop = rect.top;

    if (rect.left < 0) newLeft = 0;
    if (rect.top < 0) newTop = 0;
    if (rect.right > winWidth) newLeft = winWidth - rect.width;
    if (rect.bottom > winHeight) newTop = winHeight - rect.height;

    elmnt.style.left = newLeft + 'px';
    elmnt.style.top = newTop + 'px';
  }

  /*
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  *                                                                           *
  *                  SECTION 4: DRAGGABLE & CONTROLS                          *
  *           (Drag, flip, close, and control avatar UI!)                     *
  *                                                                           *
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  */

  // ✋ --- Make Avatar Draggable (Mouse & Touch) --- ✋
  function makeDraggable(elmnt) {
    if (scriptDisabled) return;
    let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
    let isDragging = false;
    const dragHandle = elmnt;

    // Assign start events
    dragHandle.onmousedown = dragStart;
    dragHandle.ontouchstart = dragStart;

    function dragStart(e) {
      // Don't start drag if the target is a button or slider
      if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT') {
          return;
      }
      e = e || window.event;
      isDragging = false;

      // Get the initial cursor position
      if (e.type === 'touchstart') {
        // --- Prevent page scrolling on drag start ---
        e.preventDefault();
        pos3 = e.touches[0].clientX;
        pos4 = e.touches[0].clientY;
        // Assign touch-specific listeners
        document.ontouchmove = elementDrag;
        document.ontouchend = dragEnd;
      } else {
        e.preventDefault();
        pos3 = e.clientX;
        pos4 = e.clientY;
        // Assign mouse-specific listeners
        document.onmousemove = elementDrag;
        document.onmouseup = dragEnd;
      }
    }

    function elementDrag(e) {
      isDragging = true;
      e = e || window.event;
      e.preventDefault();

      let clientX, clientY;
      // Get the new cursor position
      if (e.type === 'touchmove') {
        clientX = e.touches[0].clientX;
        clientY = e.touches[0].clientY;
      } else {
        clientX = e.clientX;
        clientY = e.clientY;
      }

      // Calculate the displacement
      pos1 = pos3 - clientX;
      pos2 = pos4 - clientY;
      pos3 = clientX;
      pos4 = clientY;

      // Calculate the new element position
      const newTop = elmnt.offsetTop - pos2;
      const newLeft = elmnt.offsetLeft - pos1;

      // Boundary collision detection
      const maxLeft = window.innerWidth - elmnt.offsetWidth;
      const maxTop = window.innerHeight - elmnt.offsetHeight;

      // Set the element's new position, respecting boundaries
      elmnt.style.top = Math.max(0, Math.min(newTop, maxTop)) + "px";
      elmnt.style.left = Math.max(0, Math.min(newLeft, maxLeft)) + "px";
    }

    function dragEnd(e) {
      // Check if it was a tap (no dragging occurred)
      if (!isDragging && e.type === 'touchend') {
          const controls = elmnt.querySelector('.emotion-avatar-controls');
          const slider = elmnt.querySelector('.emotion-avatar-slider-fade');
          if (controls && slider) {
              const isVisible = controls.style.opacity === "1";
              controls.style.opacity = isVisible ? "0" : "1";
              slider.style.opacity = isVisible ? "0" : "1";
          }
      }

      // Unbind all document-level events
      document.onmouseup = null;
      document.onmousemove = null;
      document.ontouchend = null;
      document.ontouchmove = null;

      // Reset the dragging flag
      isDragging = false;
    }
  }

  // 🕹️ --- Add Control Buttons (Close, Flip) --- 🕹️
  function addControlButtons(container, avatar, sliderContainer) {
    const controls = document.createElement("div");
    controls.classList.add("emotion-avatar-controls");
    controls.style.opacity = "0";
    sliderContainer.style.opacity = "0";
    let hideTimeout;

    const showControls = () => {
      controls.style.opacity = "1";
      sliderContainer.style.opacity = "1";
      clearTimeout(hideTimeout);
    };

    const hideControls = () => {
      controls.style.opacity = "0";
      sliderContainer.style.opacity = "0";
    };

    const startHideTimer = () => {
      hideTimeout = setTimeout(hideControls, 2000);
    };

    container.addEventListener("mouseenter", showControls);
    container.addEventListener("mouseleave", startHideTimer);

    // --- Mobile: Tap avatar to show controls, tap outside to hide ---
    if (!container._mobileTapHandlerAdded) {
      container._mobileTapHandlerAdded = true;

      // Show controls on avatar tap
      container.addEventListener("touchstart", function(e) {
        // Only trigger if tapping the avatar itself, not controls or slider
        if (
          e.target === avatar ||
          e.target === container
        ) {
          showControls();
          e.stopPropagation();
        }
      });

      // Hide controls when tapping outside avatar/controls/slider
      document.addEventListener("touchstart", function hideOnOutsideTap(e) {
        if (
          !container.contains(e.target)
        ) {
          hideControls();
        }
      });
    }

    // --- Button creation ---
    const closeButton = document.createElement("button");
    closeButton.innerHTML = `
      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:auto;" class="lucide lucide-x-icon lucide-x">
        <path d="M18 6 6 18"/><path d="m6 6 12 12"/>
      </svg>
    `;
    let closeHandled = false;
    const closeHandler = (e) => {
      e.stopPropagation();
      if (e.type === "touchstart") {
        closeHandled = true;
      }
      container.remove();
      delete botAvatars[Object.keys(botAvatars).find(key => botAvatars[key].avatar === avatar)];
      scriptDisabled = true;
    };
    closeButton.addEventListener("touchstart", function(e) {
      closeHandler(e);
    }, { passive: false });
    closeButton.addEventListener("click", function(e) {
      if (closeHandled) {
        closeHandled = false;
        return;
      }
      closeHandler(e);
    });

    const flipButton = document.createElement("button");
    flipButton.innerHTML = `
      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:auto;" class="lucide lucide-arrow-left-right-icon lucide-arrow-left-right">
        <path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/>
      </svg>
    `;
    let flipHandled = false;
    const flipHandler = (e) => {
      e.stopPropagation();
      if (e.type === "touchstart") {
        flipHandled = true;
      }
      avatar.style.transform = avatar.style.transform === "scaleX(-1)" ? "scaleX(1)" : "scaleX(-1)";
    };
    flipButton.addEventListener("touchstart", function(e) {
      flipHandler(e);
    }, { passive: false });
    flipButton.addEventListener("click", function(e) {
      if (flipHandled) {
        flipHandled = false;
        return;
      }
      flipHandler(e);
    });

    controls.appendChild(closeButton);
    controls.appendChild(flipButton);

    return controls;
  }

  // 🔍 --- Analyze Emotion with Gemini --- 🔍
  function analyzeEmotion(characterName, messageText) {
    if (scriptDisabled) return;

    /*
    *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
    *                                                                           *
    *                  SECTION 5: GEMINI PROMPT TEMPLATE                        *
    *           (The prompt sent to Gemini is customizable! ✨)                  *
    *                                                                           *
    *   📝 You can edit the prompt below to change how Gemini analyzes           *
    *   character emotions. If you add/remove emotions or change their           *
    *   descriptions/numbers in your avatar config, update the prompt list       *
    *   here to match! This helps keep the emotion detection and avatar images   *
    *   in sync for your characters and preferences.                            *
    *                                                                           *
    *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
    */

    GM_log("Sending message to Gemini for emotion analysis...");
    const prompt = `Analyze the following text and determine ${characterName}'s LAST DISPLAYED emotion based solely on their physical expressions, tone of voice, body language, and observable actions in the FINAL SENTENCE OR PHRASE.\n\nIgnore all internal thoughts, motivations, or non-visible context. Classify the emotion based on how a neutral observer would interpret their behavior at that moment. Focus ONLY on the visible and audible cues in the last part of the text.\n\nExample: If the character smiles warmly while gripping a knife (without visible tension), the emotion is "17" (Joy). If they cry silently with clenched fists, it's "26" (Sadness).\n\nRespond ONLY with the NUMBER corresponding to the most fitting emotion from this list, considering ONLY the LAST emotion shown:\n\n1. Admiration\n2. Amusement\n3. Anger\n4. Annoyance\n5. Approval\n6. Caring\n7. Confusion\n8. Curiosity\n9. Desire\n10. Disappointment\n11. Disapproval\n12. Disgust\n13. Embarrassment\n14. Excitement\n15. Fear\n16. Gratitude\n17. Joy\n18. Love\n19. Nervousness\n20. Neutral\n21. Optimism\n22. Pride\n23. Realization\n24. Relief\n25. Remorse\n26. Sadness\n27. Surprise\n\n${messageText}`;
    const payload = { contents: [{ role: "user", parts: [{ text: prompt }] }] };

    GM_xmlhttpRequest({
      method: "POST",
      url: endpoint,
      headers: { "Content-Type": "application/json" },
      data: JSON.stringify(payload),
      onload: function (res) {
        try {
          const response = JSON.parse(res.responseText);
          const emotionNumber = parseInt(response.candidates?.[0]?.content?.parts?.[0]?.text) || 20;
          addEmotionAvatar(emotionNumber, characterName);
        } catch (e) {
          console.error("Error processing response:", e.message);
        }
      },
      onerror: function (err) {
        console.error("Error request:", err.message);
      },
    });
  }

  /*
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  *                                                                           *
  *                  SECTION 6: JANITORAI CHAT INTEGRATION                    *
  *           (Hooks into JanitorAI chat to track bot messages)               *
  *                                                                           *
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  */

  // 💬 --- Chat Selectors --- 💬
  const CHAT_CONTAINER_SELECTOR = '[class^="_messagesMain_"]';
  const MESSAGE_CONTAINER_SELECTOR = '[data-testid="virtuoso-item-list"] > div[data-index]';
  const BOT_NAME_ICON_SELECTOR = '[class^="_nameIcon_"]';
  const LAST_MESSAGE_SWIPE_CONTAINER_SELECTOR = '[class^="_botChoicesContainer_"]';
  const SWIPE_SLIDER_SELECTOR = '[class^="_botChoicesSlider_"]';
  const MESSAGE_WRAPPER_SELECTOR = 'li[class^="_messageDisplayWrapper_"]';
  const MESSAGE_TEXT_SELECTOR = ".css-ji4crq p";
  const EDIT_PANEL_SELECTOR = '[class^="_editPanel_"]';
  const CONTROL_PANEL_SELECTOR = '[class^="_controlPanel_"]';
  const BOT_NAME_SELECTOR = '[class^="_nameText_"]';

  let lastLoggedText = "";
  let lastLoggedStatus = "";
  let lastLoggedSwipeIndex = -1;
  let lastLoggedMessageIndex = -1;

  // 📝 --- Log Message Status --- 📝
  /**
   * Finds the last bot message, determines its state (swipe, text, status),
   * and logs the information.
   */
  function logMessageStatus() {
    if (scriptDisabled) return; // <--- Early return if disabled
    const allMessageNodes = document.querySelectorAll(MESSAGE_CONTAINER_SELECTOR);
    if (allMessageNodes.length === 0) return;

    // Find the last message from a bot
    let lastBotMessageContainer = null;
    for (let i = allMessageNodes.length - 1; i >= 0; i--) {
      if (allMessageNodes[i].querySelector(BOT_NAME_ICON_SELECTOR)) {
        lastBotMessageContainer = allMessageNodes[i];
        break;
      }
    }

    if (!lastBotMessageContainer) return; // No bot messages found

    const botNameElement = lastBotMessageContainer.querySelector(BOT_NAME_SELECTOR);
    const characterName = botNameElement ? botNameElement.textContent.trim() : null;

    const messageIndex = parseInt(lastBotMessageContainer.dataset.index, 10);
    let activeMessageNode;
    let activeSwipeIndex = 0; // Default to 0 for non-swipeable messages

    // Check if this is the swipeable container or a regular message
    const swipeContainer = lastBotMessageContainer.querySelector(LAST_MESSAGE_SWIPE_CONTAINER_SELECTOR);

    if (swipeContainer) {
      // It's the last message with swipes
      const slider = swipeContainer.querySelector(SWIPE_SLIDER_SELECTOR);
      if (!slider) return;

      const transform = slider.style.transform;
      const translateX = transform ? parseFloat(transform.match(/translateX\(([-0-9.]+)%\)/)?.[1] || "0") : 0;
      activeSwipeIndex = Math.round(Math.abs(translateX) / 100);

      const allSwipes = slider.querySelectorAll(MESSAGE_WRAPPER_SELECTOR);
      if (allSwipes.length <= activeSwipeIndex) return;
      activeMessageNode = allSwipes[activeSwipeIndex];
    } else {
      // It's an older, non-swipeable message
      activeMessageNode = lastBotMessageContainer.querySelector(MESSAGE_WRAPPER_SELECTOR);
    }

    if (!activeMessageNode) return;

    const textElements = activeMessageNode.querySelectorAll(MESSAGE_TEXT_SELECTOR);
    const messageText =
      textElements.length > 0
        ? Array.from(textElements)
            .map((p) => p.textContent.trim())
            .join("\n")
        : "[No text found]";

    const isEditing = !!activeMessageNode.querySelector(EDIT_PANEL_SELECTOR);
    const isFinished = !!activeMessageNode.querySelector(CONTROL_PANEL_SELECTOR);

    let status;
    if (isEditing) {
      status = "Editing";
    } else if (isFinished) {
      status = "Finished";
    } else {
      status = "Streaming";
    }

    const shouldLog =
      status !== lastLoggedStatus ||
      activeSwipeIndex !== lastLoggedSwipeIndex ||
      messageIndex !== lastLoggedMessageIndex ||
      (status !== "Streaming" && messageText !== lastLoggedText);

    if (shouldLog) {
      GM_log(`Message Index: ${messageIndex} (Swipe ${activeSwipeIndex + 1}):`);

      if (status === "Streaming") {
        GM_log(`Status: ${status}`);
      } else {
        GM_log(`Text: "${messageText}"`);
        GM_log(`Status: ${status}`);
        if (status === "Finished" && messageText !== "[No text found]" && characterName) {
          analyzeEmotion(characterName, messageText);
        }
      }
      GM_log("--------------------");

      lastLoggedStatus = status;
      lastLoggedSwipeIndex = activeSwipeIndex;
      lastLoggedMessageIndex = messageIndex;

      if (status !== "Streaming") {
        lastLoggedText = messageText;
      } else {
        lastLoggedText = "";
      }
    }
  }

  // 👀 --- Initialize Observer --- 👀
  /**
   * Initializes the observer to watch for chat changes.
   */
  function initializeObserver() {
    if (scriptDisabled) return;
    const container = document.querySelector(CHAT_CONTAINER_SELECTOR);

    if (container) {
      const observer = new MutationObserver((mutations) => {
        logMessageStatus();
      });

      observer.observe(container, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['style'],
      });

      // Initial check
      logMessageStatus();
    } else {
      setTimeout(initializeObserver, 1000);
    }
  }

  // 🚀 --- Script Start --- 🚀
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initializeObserver);
  } else {
    initializeObserver();
  }

  /*
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  *                                                                           *
  *                  SECTION 7: UTILITY FUNCTIONS                             *
  *           (Helpers for image sizing and viewport calculations)            *
  *                                                                           *
  *・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.*.。.:*・☆・゜・*:.。.:*・*
  */

  // 📏 --- Get Visible Area --- 📏
  function getVisibleArea() {
    if (scriptDisabled) return;
    return {
      width: window.innerWidth,
      height: window.innerHeight
    };
  }

  // 🖼️ --- Get Image Natural Size --- 🖼️
  function getImageNaturalSize(img, callback) {
    if (scriptDisabled) return;
    if (img.complete) {
      callback({ width: img.naturalWidth, height: img.naturalHeight });
    } else {
      img.onload = () => {
        callback({ width: img.naturalWidth, height: img.naturalHeight });
      };
    }
  }

  // 📐 --- Get Max Avatar Size --- 📐
  function getMaxAvatarSize(img, callback) {
    if (scriptDisabled) return;
    getImageNaturalSize(img, ({ width: imgW, height: imgH }) => {
      const { width: vpW, height: vpH } = getVisibleArea();
      const maxW = vpW - 50;
      const maxH = vpH - 50;
      // Maintain aspect ratio
      const aspect = imgW / imgH;
      let finalW = maxW, finalH = maxW / aspect;
      if (finalH > maxH) {
        finalH = maxH;
        finalW = maxH * aspect;
      }
      callback({ width: Math.floor(finalW), height: Math.floor(finalH) });
    });
  }
})();