Perplexity Status HUD

Show Perplexity rate limits, settings, and model match/mismatch HUD

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Perplexity Status HUD
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Show Perplexity rate limits, settings, and model match/mismatch HUD
// @match        https://www.perplexity.ai/*
// @match        https://perplexity.ai/*
// @run-at       document-end
// @grant        none
// @noframes
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const d = document;
  const LS = 'ppx_v16_pos';
  const MS = 'ppx_mod';

  let M = { d: null, s: null, h: [], t: null };
  try {
    const sv = localStorage.getItem(MS);
    if (sv) M = JSON.parse(sv);
  } catch (_) {}

  function svM() {
    try {
      localStorage.setItem(MS, JSON.stringify(M));
    } catch (_) {}
  }

  function exM(t) {
    if (!t || (!t.includes('display_model') && !t.includes('user_selected_model'))) return null;

    try {
      const j = JSON.parse(t);
      const f = {};

      function w(v) {
        if (!v || typeof v !== 'object') return;
        if (typeof v.display_model === 'string') f.d = v.display_model;
        if (typeof v.user_selected_model === 'string') f.s = v.user_selected_model;
        if (f.d && f.s) return;
        for (const k in v) {
          if (Object.prototype.hasOwnProperty.call(v, k)) w(v[k]);
        }
      }

      w(j);
      if (f.d || f.s) return f;
    } catch (e) {}

    const dm = /"display_model"\s*:\s*"([^"]+)"/.exec(t);
    const us = /"user_selected_model"\s*:\s*"([^"]+)"/.exec(t);
    if (dm || us) return { d: dm ? dm[1] : null, s: us ? us[1] : null };
    return null;
  }

  const oF = window.fetch;
  window.fetch = function (...a) {
    return oF.apply(this, a).then(r => {
      try {
        r.clone().text().then(t => {
          const m = exM(t);
          if (m && (m.d || m.s)) {
            if (M.d !== m.d || M.s !== m.s) {
              M.d = m.d || M.d;
              M.s = m.s || M.s;
              M.t = Date.now();
              if (!M.h.length || M.h[0].d !== M.d) {
                M.h.unshift({ d: M.d, s: M.s, t: M.t });
                if (M.h.length > 5) M.h.pop();
              }
              svM();
              upd();
            }
          }
        }).catch(() => {});
      } catch (_) {}
      return r;
    });
  };

  const existing = d.getElementById('ppx-hud');
  if (existing) existing.remove();

  const st = d.createElement('style');
  st.textContent = `
.px-h {
  position: fixed;
  z-index: 999999;
  background: rgba(15, 23, 42, 0.95);
  backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 12px;
  padding: 10px 14px;
  box-shadow: 0 10px 25px rgba(0,0,0,0.5);
  font-family: -apple-system,system-ui,sans-serif;
  user-select: none;
  cursor: grab;
  min-width: 160px;
  transition: opacity 0.2s;
}
.px-h:active { cursor: grabbing; }
.px-h:hover .px-hc { opacity: 1; }
.px-hc {
  position: absolute;
  top: -8px;
  right: -8px;
  display: flex;
  gap: 4px;
  opacity: 0;
  transition: opacity 0.2s;
}
.px-b {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: none;
  background: #3b82f6;
  color: #fff;
  font-size: 12px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.px-b.c { background: #475569; }
.px-b:hover { background: #2563eb; }
.px-b.c:hover { background: #64748b; }
.px-r {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 5px;
}
.px-k {
  color: #94a3b8;
  font-size: 11px;
  text-transform: uppercase;
  font-weight: 600;
  letter-spacing: 0.5px;
}
.px-v {
  color: #f8fafc;
  font-size: 13px;
  font-weight: 700;
  font-family: monospace;
}
.px-w { color: #fbbf24; }
.px-s {
  height: 1px;
  background: rgba(255,255,255,0.1);
  margin: 10px 0 6px;
}
.px-f {
  font-size: 11px;
  color: #cbd5e1;
  text-align: right;
}
.px-m-bg {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: rgba(0,0,0,0.85);
  z-index: 1000000;
  backdrop-filter: blur(4px);
}
.px-m {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%,-50%);
  width: 90%;
  max-width: 600px;
  background: #1e293b;
  border-radius: 16px;
  border: 1px solid rgba(255,255,255,0.1);
  box-shadow: 0 25px 60px rgba(0,0,0,0.7);
  font-family: -apple-system,system-ui,sans-serif;
  overflow: hidden;
  max-height: 90vh;
  display: flex;
  flex-direction: column;
}
.px-mh {
  padding: 20px;
  background: rgba(255,255,255,0.03);
  border-bottom: 1px solid rgba(255,255,255,0.05);
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.px-mt {
  font-size: 18px;
  font-weight: 700;
  color: #f1f5f9;
}
.px-mc { display: flex; gap: 8px; }
.px-mb { padding: 24px; overflow-y: auto; }
.px-g {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  margin-bottom: 20px;
}
.px-c {
  background: rgba(255,255,255,0.03);
  padding: 14px;
  border-radius: 12px;
  border: 1px solid rgba(255,255,255,0.05);
}
.px-cl {
  font-size: 11px;
  color: #94a3b8;
  text-transform: uppercase;
  font-weight: 600;
  margin-bottom: 4px;
}
.px-cv {
  font-size: 18px;
  font-weight: 700;
  color: #f8fafc;
  margin-bottom: 2px;
}
.px-cv.l { font-size: 22px; }
.px-cd {
  font-size: 12px;
  color: #94a3b8;
  line-height: 1.3;
}
.px-st {
  font-size: 13px;
  font-weight: 600;
  color: #e2e8f0;
  margin-bottom: 10px;
}
.px-tg {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.px-t {
  background: rgba(255,255,255,0.05);
  border: 1px solid rgba(255,255,255,0.1);
  padding: 6px 10px;
  border-radius: 6px;
  font-size: 12px;
  color: #cbd5e1;
  display: flex;
  gap: 6px;
}
.px-tv {
  font-family: monospace;
  color: #818cf8;
  font-weight: 700;
}
.px-sp { animation: s 1s linear infinite; }
@keyframes s { 100% { transform: rotate(360deg); } }
.px-ms {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  font-size: 10px;
  font-weight: bold;
  font-family: system-ui;
  line-height: 1;
  color: #fff;
}
.px-md-w { background: #64748b; }
.px-md-o { background: #10b981; }
.px-md-e {
  background: #ef4444;
  animation: p 2s infinite;
}
@keyframes p {
  0%,100% { opacity: 1; }
  50% { opacity: 0.5; }
}
.px-wb {
  word-break: break-all;
  line-height: 1.2;
  font-size: 13px;
  font-family: monospace;
  color: #cbd5e1;
  margin-bottom: 8px;
}
`;
  d.head.appendChild(st);

  const h = d.createElement('div');
  h.id = 'ppx-hud';
  h.className = 'px-h';

  const p = JSON.parse(localStorage.getItem(LS) || 'null');
  if (p) {
    h.style.left = `${p.x}px`;
    h.style.top = `${p.y}px`;
  } else {
    h.style.top = '20px';
    h.style.right = '80px';
  }

  h.innerHTML = '<div style="font-size:12px;color:#94a3b8;text-align:center">Loading...</div>';
  d.body.appendChild(h);

  let isDragging = false;
  let sX, sY, iL, iT;

  h.addEventListener('mousedown', e => {
    if (e.target.tagName === 'BUTTON') return;
    isDragging = false;
    sX = e.clientX;
    sY = e.clientY;
    iL = h.offsetLeft;
    iT = h.offsetTop;

    const mH = m => {
      const dX = m.clientX - sX;
      const dY = m.clientY - sY;
      if (Math.sqrt(dX * dX + dY * dY) > 5) {
        isDragging = true;
        h.style.left = `${iL + dX}px`;
        h.style.top = `${iT + dY}px`;
        h.style.right = 'auto';
      }
    };

    const uH = () => {
      window.removeEventListener('mousemove', mH);
      window.removeEventListener('mouseup', uH);
      if (isDragging) {
        localStorage.setItem(LS, JSON.stringify({ x: h.offsetLeft, y: h.offsetTop }));
      }
    };

    window.addEventListener('mousemove', mH);
    window.addEventListener('mouseup', uH);
  });

  let cd;

  function fd() {
    return Promise.all([
      fetch('/rest/rate-limit/all').then(r => r.json()),
      fetch('/rest/user/settings').then(r => r.json())
    ]).then(a => {
      cd = a;
      return a;
    });
  }

  function uM() {
    const mt = M.d && M.s && M.d === M.s;
    const ic = !M.d ? '⏯' : (mt ? '✓' : '!');
    const cl = !M.d ? 'px-md-w' : (mt ? 'px-md-o' : 'px-md-e');
    const tt = !M.d ? 'Awaiting query...' : `Req: ${M.s}\nGot: ${M.d}`;
    return `
      <div class="px-r" title="${tt}">
        <span class="px-k">AI Model</span>
        <span class="px-ms ${cl}">${ic}</span>
      </div>
    `;
  }

  function upd() {
    if (!cd) return;
    const r = cd[0];
    const u = cd[1];

    const rw = (k, v, w) => `
      <div class="px-r">
        <span class="px-k">${k}</span>
        <span class="px-v ${w ? 'px-w' : ''}">${v}</span>
      </div>
    `;

    h.innerHTML = `
      <div class="px-hc">
        <button class="px-b" id="px-rb" title="Reload">⟳</button>
        <button class="px-b c" onclick="this.closest('#ppx-hud').remove()" title="Close">×</button>
      </div>
      ${rw('Uploads', u.upload_limit, u.upload_limit < 10)}
      ${rw('Pro search', r.remaining_pro)}
      ${rw('Research', r.remaining_research)}
      ${rw('Labs', r.remaining_labs)}
      ${uM()}
      <div class="px-s"></div>
      <div class="px-f">Total queries: ${(u.query_count || 0).toLocaleString()}</div>
    `;

    d.getElementById('px-rb').onclick = e => {
      e.stopPropagation();
      rf();
    };

    h.onclick = e => {
      if (!isDragging && e.target.tagName !== 'BUTTON') oM();
    };
  }

  function gM(r, u) {
    let sr = '';
    let cn = '';

    if (r.sources && r.sources.source_to_limit) {
      const l = [];
      Object.keys(r.sources.source_to_limit).forEach(k => {
        const s = r.sources.source_to_limit[k];
        if (s.monthly_limit > 0) {
          l.push({
            n: k.replace(/_mcp_cashmere|_mcp_merge|_direct/g, '').replace(/_/g, ' '),
            v: `${s.remaining}/${s.monthly_limit}`
          });
        }
      });
      if (l.length) {
        sr = `
          <div style="margin-bottom:20px">
            <div class="px-st">📊 Source Limits (Monthly)</div>
            <div class="px-tg">
              ${l.map(i => `
                <div class="px-t">
                  <span style="text-transform:capitalize">${i.n}</span>
                  <span class="px-tv">${i.v}</span>
                </div>
              `).join('')}
            </div>
          </div>
        `;
      }
    }

    if (u.connectors && u.connectors.connectors) {
      const cL = u.connectors.connectors.filter(c => c.connected).map(c => c.name);
      cn = cL.length ? cL.join(', ') : 'None connected';
    }

    let hs = '';
    if (M.h.length) {
      hs = M.h.map(i => {
        const tm = new Date(i.t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
        const c = i.d === i.s ? '#10b981' : '#ef4444';
        return `
          <div style="font-size:12px;color:#94a3b8;padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.05);display:flex;justify-content:space-between">
            <span>${tm}</span>
            <span style="font-family:monospace;color:${c}">${i.d}</span>
          </div>
        `;
      }).join('');
    }

    const mt = M.d && M.s && M.d === M.s;
    const ms = !M.d ? 'Awaiting query...' : (mt ? 'MATCH ✓' : 'MISMATCH ⚠️');
    const mc = !M.d ? '#94a3b8' : (mt ? '#10b981' : '#ef4444');

    return `
      <div class="px-g">
        <div class="px-c">
          <div class="px-cl">File Uploads</div>
          <div class="px-cv l ${u.upload_limit < 10 ? 'px-w' : ''}">${u.upload_limit ?? 'N/A'}</div>
          <div class="px-cd">Manual thread attachments<br>(Weekly limit)</div>
        </div>
        <div class="px-c">
          <div class="px-cl">Pro Search</div>
          <div class="px-cv l">${r.remaining_pro}</div>
          <div class="px-cd">Standard Pro queries<br>(Daily soft limit)</div>
        </div>
        <div class="px-c">
          <div class="px-cl">Deep Research</div>
          <div class="px-cv l">${r.remaining_research}</div>
          <div class="px-cd">Comprehensive reports<br>(Monthly limit)</div>
        </div>
        <div class="px-c">
          <div class="px-cl">Labs</div>
          <div class="px-cv l">${r.remaining_labs}</div>
          <div class="px-cd">Create files & apps<br>(Monthly limit)</div>
        </div>
      </div>
      <div style="margin-bottom:20px;border:1px solid ${mc}40;border-radius:12px;background:rgba(255,255,255,0.02);padding:16px">
        <div class="px-st">🤖 AI Model Monitoring</div>
        <div class="px-g" style="margin-bottom:0;gap:20px">
          <div style="display:flex;flex-direction:column;justify-content:center">
            <div style="font-size:16px;font-weight:700;color:${mc};margin-bottom:12px">${ms}</div>
            <div class="px-cl">Selected</div>
            <div class="px-wb">${M.s || '—'}</div>
            <div class="px-cl" style="margin-top:8px">Received</div>
            <div class="px-wb">${M.d || '—'}</div>
          </div>
          <div>
            <div class="px-cl" style="margin-bottom:8px">Recent History</div>
            ${hs || '<div style="font-size:12px;color:#64748b">No queries yet</div>'}
          </div>
        </div>
      </div>
      <div style="margin-bottom:20px">
        <div class="px-st">🛡️ Account & Features</div>
        <div class="px-g" style="grid-template-columns:1fr 1fr 1fr 1fr;gap:8px">
          <div class="px-c" style="padding:10px">
            <div class="px-cl">Tier</div>
            <div class="px-cv" style="font-size:14px">${(u.subscription_tier === 'unknown' ? 'Pro' : u.subscription_tier).toUpperCase()}</div>
          </div>
          <div class="px-c" style="padding:10px">
            <div class="px-cl">Training</div>
            <div class="px-cv" style="font-size:14px">${u.disable_training ? 'OFF' : 'ON ⚠️'}</div>
          </div>
          <div class="px-c" style="padding:10px">
            <div class="px-cl">Img Model</div>
            <div class="px-cv" style="font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${u.default_image_generation_model}">
              ${u.default_image_generation_model || 'Default'}
            </div>
          </div>
          <div class="px-c" style="padding:10px">
            <div class="px-cl">Pages</div>
            <div class="px-cv" style="font-size:14px">${u.pages_limit || 100} Cap</div>
          </div>
        </div>
        <div style="font-size:12px;color:#cbd5e1;margin-top:8px">
          <strong>Connectors:</strong> ${cn}
        </div>
      </div>
      ${sr}
      <div style="display:flex;justify-content:center;gap:12px;font-size:13px;color:#94a3b8;margin-top:20px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1)">
        <span>Total Queries: <strong style="color:#e2e8f0">${(u.query_count || 0).toLocaleString()}</strong></span>
        <span>•</span>
        <span>${u.time_zone || 'Unknown Region'}</span>
      </div>
    `;
  }

  function oM() {
    if (!cd) return;
    const r = cd[0];
    const u = cd[1];

    const bg = d.createElement('div');
    bg.className = 'px-m-bg';
    bg.id = 'px-bg';
    bg.onclick = e => {
      if (e.target === bg) bg.remove();
    };

    bg.innerHTML = `
      <div class="px-m">
        <div class="px-mh">
          <div class="px-mt">⚡ Perplexity Status</div>
          <div class="px-mc">
            <button class="px-b" style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,0.1);font-size:16px" id="px-mr" title="Reload">⟳</button>
            <button class="px-b" style="width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,0.1);font-size:16px" onclick="this.closest('.px-m-bg').remove()" title="Close">×</button>
          </div>
        </div>
        <div class="px-mb" id="px-mb-c">
          ${gM(r, u)}
        </div>
      </div>
    `;

    d.body.appendChild(bg);

    d.getElementById('px-mr').onclick = () => rf();
  }

  function rf() {
    const rb = d.getElementById('px-rb');
    const mr = d.getElementById('px-mr');
    if (rb) rb.classList.add('px-sp');
    if (mr) mr.classList.add('px-sp');

    fd().then(a => {
      upd();
      const mc = d.getElementById('px-mb-c');
      if (mc) mc.innerHTML = gM(a[0], a[1]);
    }).finally(() => {
      if (rb) rb.classList.remove('px-sp');
      if (mr) mr.classList.remove('px-sp');
    });
  }

  rf();
})();