JanitorAI – Right Arrow Generate Indicator

The right arrow turns green when the next click would generate a new output.

当前为 2025-08-08 提交的版本,查看 最新版本

// ==UserScript==
// @name         JanitorAI – Right Arrow Generate Indicator
// @namespace    gf:jai-green-right
// @version      1.0.2
// @description  The right arrow turns green when the next click would generate a new output.
// @match        https://janitorai.com/chats*
// @license      MIT
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const SEL = {
    right:  'button[aria-label="Right"]',
    left:   'button[aria-label="Left"]',
    slider: 'div[class*="_botChoicesSlider_"]',
  };

  const style = document.createElement('style');
  style.textContent = `
    .jai-green-arrow { outline: 3px solid #22c55e !important; outline-offset: 2px !important; border-radius: 999px !important; }
    .jai-green-arrow svg { color: #22c55e !important; fill: currentColor !important; }
  `;
  document.head.appendChild(style);

  const $  = (s, r = document) => r.querySelector(s);
  const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));

  let ctx = {
    right: null, left: null, slider: null, container: null,
    counterEl: null, sliderMO: null, counterMO: null, rebinderMO: null, pulseTimer: null
  };

  // ---------- utils ----------
  function isVisible(el) {
    if (!el) return false;
    const cs = getComputedStyle(el);
    if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
    const r = el.getBoundingClientRect();
    return r.width > 8 && r.height > 8;
  }

  function getViewport(slider) {
    let el = slider?.parentElement;
    while (el && el !== document.body) {
      const ox = getComputedStyle(el).overflowX;
      if (ox && ox !== 'visible') return el;
      el = el.parentElement;
    }
    return slider?.parentElement || slider;
  }

  // ---------- pairing ----------
  function bottomMostRight() {
    const candidates = $$(SEL.right).filter(isVisible);
    if (!candidates.length) return null;
    candidates.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
    return candidates[candidates.length - 1];
  }

  function findNearestSliderFrom(node) {
    let el = node;
    for (let i = 0; el && i < 12; i++, el = el.parentElement) {
      const s = $(SEL.slider, el);
      if (s && isVisible(s)) return s;
    }
    const all = $$(SEL.slider).filter(isVisible);
    return all[all.length - 1] || null;
  }

  function findContainerFrom(node) {
    // Walk upwards to a block that contains both arrows and the slider
    let el = node;
    for (let i = 0; el && i < 12; i++, el = el.parentElement) {
      const hasRight = $(SEL.right, el);
      const hasLeft  = $(SEL.left, el);
      const hasSlider= $(SEL.slider, el);
      if ((hasRight || hasLeft) && hasSlider) return el;
    }
    return null;
  }

  function findCounterEl(container) {
    if (!container) return null;
    // Look for a short text like "1/3" or "1 / 3" near the arrows
    const nodes = $$( 'span,div,p,small,strong,em', container ).filter(isVisible);
    let best = null, bestDist = Infinity;
    const right = $(SEL.right, container);
    const rbox = right ? right.getBoundingClientRect() : null;

    for (const n of nodes) {
      const t = (n.textContent || '').trim();
      if (t.length > 10) continue;
      const m = t.match(/^(\d+)\s*\/\s*(\d+)$/);
      if (!m) continue;
      if (!rbox) { best = n; break; }
      const box = n.getBoundingClientRect();
      const dist = Math.hypot((box.left + box.right)/2 - (rbox.left + rbox.right)/2,
                              (box.top + box.bottom)/2 - (rbox.top + rbox.bottom)/2);
      if (dist < bestDist) { bestDist = dist; best = n; }
    }
    return best;
  }

  function pair() {
    const right = bottomMostRight();
    const slider = right ? findNearestSliderFrom(right) : null;
    const container = right ? findContainerFrom(right) : null;
    const left = container ? $(SEL.left, container) : null;
    const counterEl = findCounterEl(container);

    const changed = right !== ctx.right || slider !== ctx.slider || left !== ctx.left || container !== ctx.container || counterEl !== ctx.counterEl;
    if (!changed) return;

    if (ctx.sliderMO) ctx.sliderMO.disconnect();
    if (ctx.counterMO) ctx.counterMO.disconnect();

    ctx.right = right;
    ctx.left = left;
    ctx.slider = slider;
    ctx.container = container;
    ctx.counterEl = counterEl;

    if (ctx.right) ctx.right.addEventListener('click', () => pulse(3000), { capture: true });
    if (ctx.left)  ctx.left.addEventListener('click',  () => pulse(2000), { capture: true });

    if (ctx.slider) {
      ctx.sliderMO = new MutationObserver(() => {
        setTimeout(updateTint, 30);
        setTimeout(updateTint, 200);
        setTimeout(updateTint, 700);
      });
      ctx.sliderMO.observe(ctx.slider, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });

      const vp = getViewport(ctx.slider);
      vp.addEventListener('scroll', updateTint, { passive: true });
      vp.addEventListener('transitionend', updateTint, { passive: true });
      vp.addEventListener('animationend', updateTint, { passive: true });
    }

    if (ctx.counterEl) {
      ctx.counterMO = new MutationObserver(() => {
        setTimeout(updateTint, 10);
        setTimeout(updateTint, 150);
      });
      ctx.counterMO.observe(ctx.counterEl, { childList: true, subtree: true, characterData: true });
    }

    updateTint();
  }

  // ---------- index & counter ----------
  function sliderItems(slider) {
    return $$(':scope > *', slider).filter(isVisible);
  }

  function indexFromTransform(slider, items) {
    const inline = slider.style.transform || '';
    let m = inline.match(/translateX\((-?\d+(?:\.\d+)?)%\)/);
    if (m) return clamp(Math.round(Math.abs(parseFloat(m[1])) / 100), 0, items.length - 1);

    const comp = getComputedStyle(slider).transform;
    if (comp && comp !== 'none') {
      const mm = comp.match(/matrix\(([^)]+)\)/);
      if (mm) {
        const parts = mm[1].split(',').map(s => parseFloat(s.trim()));
        const tx = parts[4] || 0;
        const w  = items[0]?.getBoundingClientRect().width || 1;
        return clamp(Math.round(Math.abs(tx) / w), 0, items.length - 1);
      }
    }
    return null;
  }

  function indexByViewport(slider, items) {
    const vp = getViewport(slider).getBoundingClientRect();
    const centerV = vp.left + vp.width / 2;
    let best = 0, bestDist = Infinity;
    for (let i = 0; i < items.length; i++) {
      const r = items[i].getBoundingClientRect();
      const dist = Math.abs((r.left + r.width / 2) - centerV);
      if (dist < bestDist) { bestDist = dist; best = i; }
    }
    return best;
  }

  function currentIndex(slider) {
    const items = sliderItems(slider);
    if (!items.length) return 0;
    return indexFromTransform(slider, items) ?? indexByViewport(slider, items);
  }

  function readCounter() {
    if (!ctx.counterEl) return null;
    const t = (ctx.counterEl.textContent || '').trim();
    const m = t.match(/^(\d+)\s*\/\s*(\d+)$/);
    if (!m) return null;
    const cur = parseInt(m[1], 10);
    const tot = parseInt(m[2], 10);
    if (!isFinite(cur) || !isFinite(tot) || tot <= 0) return null;
    return { cur, tot }; // 1-based
  }

  function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }

  // ---------- tint logic ----------
  function shouldBeGreen() {
    if (!ctx.right || !isVisible(ctx.right)) return false;

    // Prefer the explicit counter if available
    const c = readCounter();
    if (c) {
      // Green only if we're on the last known variant (cur == tot)
      return c.cur >= c.tot;
    }

    // Fallback to slider math
    if (!ctx.slider || !isVisible(ctx.slider)) return false;
    const items = sliderItems(ctx.slider);
    if (!items.length) return false;
    const idx = currentIndex(ctx.slider); // 0-based
    return idx >= items.length - 1;
  }

  function updateTint() {
    if (!ctx.right) return;
    ctx.right.classList.toggle('jai-green-arrow', shouldBeGreen());
  }

  // ---------- keep it fresh ----------
  function pulse(ms) {
    if (ctx.pulseTimer) clearInterval(ctx.pulseTimer);
    const endAt = performance.now() + ms;
    ctx.pulseTimer = setInterval(() => {
      if (performance.now() > endAt) {
        clearInterval(ctx.pulseTimer);
        ctx.pulseTimer = null;
      }
      pair();
      updateTint();
    }, 160);
  }

  function startRebinder() {
    if (ctx.rebinderMO) ctx.rebinderMO.disconnect();
    ctx.rebinderMO = new MutationObserver(() => {
      if (!document.contains(ctx.right) || !document.contains(ctx.slider) || (ctx.counterEl && !document.contains(ctx.counterEl))) {
        pair();
      }
    });
    ctx.rebinderMO.observe(document.body, { childList: true, subtree: true });
  }

  // Pulse after generation POSTs (new variant added)
  (function patchNetwork() {
    const looksGen = (url, method) =>
      method === 'POST' &&
      /janitorai\.com/i.test(url) &&
      /(generate|regenerate|completion|completions|respond|messages|chat)/i.test(url);

    const _fetch = window.fetch;
    window.fetch = function(resource, init = {}) {
      try {
        const url = typeof resource === 'string' ? resource : resource.url;
        const method = (init?.method || 'GET').toUpperCase();
        if (looksGen(url, method)) pulse(5000);
      } catch {}
      return _fetch.apply(this, arguments);
    };

    const XO = XMLHttpRequest.prototype.open;
    const XS = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function(method, url) {
      this._m = (method || 'GET').toUpperCase();
      this._u = url || '';
      return XO.apply(this, arguments);
    };
    XMLHttpRequest.prototype.send = function(body) {
      try {
        if (looksGen(this._u || '', this._m || 'GET')) pulse(5000);
      } catch {}
      return XS.apply(this, arguments);
    };
  })();

  // SPA nav + boot
  function hookSpa() {
    const origPush = history.pushState;
    history.pushState = function () {
      const r = origPush.apply(this, arguments);
      setTimeout(() => { pair(); updateTint(); }, 400);
      return r;
    };
    window.addEventListener('popstate', () => setTimeout(() => { pair(); updateTint(); }, 400));
    window.addEventListener('resize', updateTint);
  }

  function boot() {
    pair();
    startRebinder();
    hookSpa();

    let tries = 0;
    const iv = setInterval(() => {
      pair();
      if (++tries > 40 || (ctx.right && ctx.slider)) clearInterval(iv);
    }, 200);
  }

  boot();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址