// ==UserScript==
// @name ChatGPT用量统计
// @namespace https://github.com/tizee/tampermonkey-chatgpt-model-usage-monitor
// @version 2.0.0
// @description Elegant usage monitor for ChatGPT models with sliding window quota tracking
// @author tizee (original), schweigen (modified)
// @match https://chatgpt.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// Register menu command to reset position
GM_registerMenuCommand("Reset Monitor Position", function() {
// 只重置位置,但保留其他数据
Storage.update(data => {
data.position = { x: null, y: null };
data.minimized = false; // 确保不是最小化状态
});
// 移除现有的UI元素
const existingMonitor = document.getElementById("chatUsageMonitor");
if (existingMonitor) {
existingMonitor.remove();
}
// 重新初始化脚本
console.log("[monitor] Reinitializing script...");
setTimeout(initialize, 100);
// 显示消息
setTimeout(() => {
const monitor = document.getElementById("chatUsageMonitor");
if (monitor) {
// 重置为新的默认位置(左下角)
monitor.style.setProperty('left', STYLE.spacing.lg, 'important');
monitor.style.setProperty('bottom', '100px', 'important');
monitor.style.setProperty('right', 'auto', 'important');
monitor.style.setProperty('top', 'auto', 'important');
showToast("Monitor has been reset and reloaded", "success");
} else {
alert("Monitor reset completed. If you don't see it, please refresh the page.");
}
}, 500);
});
// text-scramble animation
(()=>{var TextScrambler=(()=>{var l=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var u=Object.getOwnPropertyNames;var m=Object.prototype.hasOwnProperty;var d=(n,t)=>{for(var e in t)l(n,e,{get:t[e],enumerable:!0})},f=(n,t,e,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of u(t))!m.call(n,i)&&i!==e&&l(n,i,{get:()=>t[i],enumerable:!(s=c(t,i))||s.enumerable});return n};var g=n=>f(l({},"__esModule",{value:!0}),n);var T={};d(T,{default:()=>r});function _(n){let t=document.createTreeWalker(n,NodeFilter.SHOW_TEXT,{acceptNode:s=>s.nodeValue.trim()?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP}),e=[];for(;t.nextNode();)t.currentNode.nodeValue=t.currentNode.nodeValue.replace(/(\n|\r|\t)/gm,""),e.push(t.currentNode);return e}function p(n,t,e){return t<0||t>=n.length?n:n.substring(0,t)+e+n.substring(t+1)}function M(n,t){return n?"x":t[Math.floor(Math.random()*t.length)]}var r=class{constructor(t,e={}){this.el=t;let s={duration:1e3,delay:0,reverse:!1,absolute:!1,pointerEvents:!0,scrambleSymbols:"\u2014~\xB1\xA7|[].+$^@*()\u2022x%!?#",randomThreshold:null};this.config=Object.assign({},s,e),this.config.randomThreshold===null&&(this.config.randomThreshold=this.config.reverse?.1:.8),this.textNodes=_(this.el),this.nodeLengths=this.textNodes.map(i=>i.nodeValue.length),this.originalText=this.textNodes.map(i=>i.nodeValue).join(""),this.mask=this.originalText.split(" ").map(i=>"\xA0".repeat(i.length)).join(" "),this.currentMask=this.mask,this.totalChars=this.originalText.length,this.scrambleRange=Math.floor(this.totalChars*(this.config.reverse?.25:1.5)),this.direction=this.config.reverse?-1:1,this.config.absolute&&(this.el.style.position="absolute",this.el.style.top="0"),this.config.pointerEvents||(this.el.style.pointerEvents="none"),this._animationFrame=null,this._startTime=null,this._running=!1}initialize(){return this.currentMask=this.mask,this}_getEased(t){let e=-(Math.cos(Math.PI*t)-1)/2;return e=Math.pow(e,2),this.config.reverse?1-e:e}_updateScramble(t,e,s){if(Math.random()<.5&&t>0&&t<1)for(let i=0;i<20;i++){let o=i/20,a;if(this.config.reverse?a=e-Math.floor((1-Math.random())*this.scrambleRange*o):a=e+Math.floor((1-Math.random())*this.scrambleRange*o),!(a<0||a>=this.totalChars)&&this.currentMask[a]!==" "){let h=Math.random()>this.config.randomThreshold?this.originalText[a]:M(this.config.reverse,this.config.scrambleSymbols);this.currentMask=p(this.currentMask,a,h)}}}_composeOutput(t,e,s){let i="";if(this.config.reverse){let o=Math.max(e-s,0);i=this.mask.slice(0,o)+this.currentMask.slice(o,e)+this.originalText.slice(e)}else i=this.originalText.slice(0,e)+this.currentMask.slice(e,e+s)+this.mask.slice(e+s);return i}_updateTextNodes(t){let e=0;for(let s=0;s<this.textNodes.length;s++){let i=this.nodeLengths[s];this.textNodes[s].nodeValue=t.slice(e,e+i),e+=i}}_tick=t=>{this._startTime||(this._startTime=t);let e=t-this._startTime,s=Math.min(e/this.config.duration,1),i=this._getEased(s),o=Math.floor(this.totalChars*s),a=Math.floor(2*(.5-Math.abs(s-.5))*this.scrambleRange);this._updateScramble(s,o,a);let h=this._composeOutput(s,o,a);this._updateTextNodes(h),s<1?this._animationFrame=requestAnimationFrame(this._tick):this._running=!1};start(){this._running=!0,this._startTime=null,this.config.delay?setTimeout(()=>{this._animationFrame=requestAnimationFrame(this._tick)},this.config.delay):this._animationFrame=requestAnimationFrame(this._tick)}stop(){this._animationFrame&&(cancelAnimationFrame(this._animationFrame),this._animationFrame=null),this._running=!1}};return g(T);})();
window.TextScrambler = TextScrambler.default || TextScrambler;
})();
// Constants and Configuration
const COLORS = {
primary: "#5E9EFF",
background: "#1A1B1E",
surface: "#2A2B2E",
border: "#363636",
text: "#E5E7EB",
secondaryText: "#9CA3AF",
success: "#10B981",
warning: "#F59E0B",
danger: "#EF4444",
disabled: "#4B5563",
white: "oklch(.928 .006 264.531)",
gray: "oklch(.92 .004 286.32)",
yellow: "oklch(.905 .182 98.111)",
green: "oklch(.845 .143 164.978)",
// Red for low usage
progressLow: "#EF4444",
// Orange for medium usage
progressMed: "#F59E0B",
// Green for high usage
progressHigh: "#10B981",
// Gray for exceeded
progressExceed: "#4B5563",
// Blue for 3 hour window
hourModel: "#61DAFB",
// Purple for daily models (24h)
dailyModel: "#9F7AEA",
// Green for weekly models (7d)
weeklyModel: "#10B981",
};
const STYLE = {
borderRadius: "12px",
boxShadow:
"0 4px 6px -1px rgba(0, 0, 0, 0.2), 0 2px 4px -1px rgba(0, 0, 0, 0.1)",
spacing: {
xs: "4px",
sm: "8px",
md: "16px",
lg: "24px",
},
textSize: {
xs: "0.75rem",
sm: "0.875rem",
md: "1rem",
},
lineHeight: {
xs: "calc(1/.75)",
sm: "calc(1.25/.875)",
md: "1.5",
},
};
// Time window constants in milliseconds
const TIME_WINDOWS = {
hour3: 3 * 60 * 60 * 1000, // 3 hours in ms
daily: 24 * 60 * 60 * 1000, // 24 hours (1 day) in ms
weekly: 7 * 24 * 60 * 60 * 1000 // 7 days (1 week) in ms
};
// Helper Functions
const formatTimeAgo = (timestamp) => {
const now = Date.now();
const seconds = Math.floor((now - timestamp) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
};
const formatTimeLeft = (windowEnd) => {
const now = Date.now();
const timeLeft = windowEnd - now;
if (timeLeft <= 0) return "0h 0m";
const hours = Math.floor(timeLeft / (60 * 60 * 1000));
const minutes = Math.floor((timeLeft % (60 * 60 * 1000)) / (60 * 1000));
return `${hours}h ${minutes}m`;
};
// Calculate window end time for oldest request
const getWindowEnd = (timestamp, windowType) => {
return timestamp + TIME_WINDOWS[windowType];
};
// Default Configuration with updated model list
const defaultUsageData = {
position: { x: null, y: null },
size: { width: 400, height: 500 },
minimized: false,
progressType: "bar", // bar or dots (default to bar)
models: {
"gpt-4o": {
displayName: "GPT-4o",
requests: [],
quota: 80, // Using a reasonable limit
windowType: "hour3" // 3-hour window
},
"o4-mini": {
displayName: "o4-mini",
requests: [],
quota: 150,
windowType: "daily" // 24-hour window
},
"o4-mini-high": {
displayName: "o4-mini-high",
requests: [],
quota: 50,
windowType: "daily" // 24-hour window
},
"o3": {
displayName: "o3",
requests: [],
quota: 50,
windowType: "weekly" // 7-day window
},
"gpt-4-5": {
displayName: "gpt-4-5",
requests: [],
quota: 50,
windowType: "weekly" // 7-day window
}
},
};
// Updated Styles
GM_addStyle(`
#chatUsageMonitor {
position: fixed;
bottom: 100px; /* 往下移动一点点 */
left: ${STYLE.spacing.lg}; /* 改为左侧 */
width: 400px;
height: 500px;
max-height: 80vh;
overflow: auto;
background: ${COLORS.background};
color: ${COLORS.text};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
border-radius: ${STYLE.borderRadius};
box-shadow: ${STYLE.boxShadow};
z-index: 9999;
border: 1px solid ${COLORS.border};
user-select: none;
resize: both;
transition: all 0.3s ease;
transform-origin: top left; /* 改为左侧 */
}
#chatUsageMonitor::after {
content: "";
position: absolute;
bottom: 0;
right: 0;
width: 15px;
height: 15px;
background: transparent;
border-bottom: 2px solid ${COLORS.yellow};
border-right: 2px solid ${COLORS.yellow};
opacity: 0.5;
pointer-events: none;
}
#chatUsageMonitor:hover::after {
opacity: 1;
}
#chatUsageMonitor.minimized {
width: 30px !important;
height: 30px !important;
border-radius: 50%;
overflow: hidden;
resize: none;
opacity: 0.8;
cursor: pointer;
background-color: ${COLORS.primary};
bottom: auto;
top: 100px; /* 往下移动一点点 */
left: ${STYLE.spacing.lg}; /* 改为左侧 */
z-index: 9999;
}
#chatUsageMonitor.minimized:hover {
opacity: 1;
}
#chatUsageMonitor.minimized > * {
display: none !important;
}
#chatUsageMonitor.minimized::before {
content: "次";
color: white;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
}
#chatUsageMonitor header {
padding: 0 ${STYLE.spacing.md};
display: flex;
border-radius: ${STYLE.borderRadius} ${STYLE.borderRadius} 0 0;
background: ${COLORS.background};
flex-direction: row;
position: relative;
align-items: center;
height: 36px;
cursor: move; /* 指示整个头部可拖动 */
}
#chatUsageMonitor .minimize-btn {
position: absolute;
left: 8px;
top: 0;
height: 36px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
color: ${COLORS.secondaryText};
cursor: pointer;
font-size: 18px;
transition: color 0.2s ease;
z-index: 10;
}
#chatUsageMonitor .minimize-btn:hover {
color: ${COLORS.yellow};
}
#chatUsageMonitor header button {
border: none;
background: none;
color: ${COLORS.secondaryText};
cursor: pointer;
font-weight: 500;
transition: color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
margin-left: 30px; /* Move buttons to the right to avoid overlap with minimize button */
padding-top: ${STYLE.spacing.sm};
}
#chatUsageMonitor header button.active {
color: ${COLORS.yellow};
}
#chatUsageMonitor .content {
padding: ${STYLE.spacing.xs} ${STYLE.spacing.md};
overflow-y: auto;
}
#chatUsageMonitor .reset-info {
font-size: ${STYLE.textSize.xs};
color: ${COLORS.secondaryText};
margin: ${STYLE.spacing.xs} 0;
}
#chatUsageMonitor input {
width: 80px;
padding: ${STYLE.spacing.xs} ${STYLE.spacing.sm};
margin: 0;
border: none;
border-radius: 0;
background: transparent;
color: ${COLORS.secondaryText};
font-family: monospace;
font-size: ${STYLE.textSize.xs};
line-height: ${STYLE.lineHeight.xs};
transition: color 0.2s ease;
}
#chatUsageMonitor input:focus {
outline: none;
color: ${COLORS.yellow};
background: transparent;
}
#chatUsageMonitor input:hover {
color: ${COLORS.yellow};
}
#chatUsageMonitor .btn {
padding: ${STYLE.spacing.sm} ${STYLE.spacing.md};
border: none;
cursor: pointer;
color: ${COLORS.white};
font-weight: 500;
font-size: ${STYLE.textSize.sm};
transition: all 0.2s ease;
text-decoration: underline;
}
#chatUsageMonitor .btn:hover {
color: ${COLORS.yellow};
}
#chatUsageMonitor .delete-btn {
padding: ${STYLE.spacing.xs} ${STYLE.spacing.sm};
margin-left: ${STYLE.spacing.sm};
}
#chatUsageMonitor .delete-btn.btn:hover {
color: ${COLORS.danger};
}
#chatUsageMonitor::-webkit-scrollbar {
width: 8px;
}
#chatUsageMonitor::-webkit-scrollbar-track {
background: ${COLORS.surface};
border-radius: 4px;
}
#chatUsageMonitor::-webkit-scrollbar-thumb {
background: ${COLORS.border};
border-radius: 4px;
}
#chatUsageMonitor::-webkit-scrollbar-thumb:hover {
background: ${COLORS.secondaryText};
}
#chatUsageMonitor .progress-container {
width: 100%;
background: ${COLORS.surface};
margin-top: ${STYLE.spacing.xs};
border-radius: 6px;
overflow: hidden;
height: 8px;
position: relative;
}
#chatUsageMonitor .progress-bar {
height: 100%;
transition: width 0.3s ease;
border-radius: 6px;
background: linear-gradient(
90deg,
${COLORS.progressLow} 0%,
${COLORS.progressMed} 50%,
${COLORS.progressHigh} 100%
);
background-size: 200% 100%;
animation: gradientShift 2s linear infinite;
}
#chatUsageMonitor .progress-bar.low-usage {
animation: pulse 1.5s ease-in-out infinite;
}
#chatUsageMonitor .progress-bar.exceeded {
background: ${COLORS.progressExceed};
animation: none;
}
#chatUsageMonitor .window-badge {
display: inline-block;
font-size: 10px;
padding: 2px 4px;
border-radius: 4px;
margin-left: 4px;
color: ${COLORS.background};
font-weight: bold;
}
#chatUsageMonitor .window-badge.hour3 {
background-color: ${COLORS.hourModel};
}
#chatUsageMonitor .window-badge.daily {
background-color: ${COLORS.dailyModel};
}
#chatUsageMonitor .window-badge.weekly {
background-color: ${COLORS.weeklyModel};
}
#chatUsageMonitor .request-time {
color: ${COLORS.secondaryText};
font-size: ${STYLE.textSize.xs};
}
#chatUsageMonitor .window-info {
color: ${COLORS.secondaryText};
font-size: ${STYLE.textSize.xs};
margin-top: 2px;
}
#chatUsageMonitor .active-window {
font-weight: bold;
}
#chatUsageMonitor .unknown-quota {
color: ${COLORS.warning};
font-style: italic;
}
@keyframes gradientShift {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
}
/* Dot-based progression system */
#chatUsageMonitor .dot-progress {
display: flex;
gap: 4px;
align-items: center;
height: 8px;
}
#chatUsageMonitor .dot {
width: 8px;
height: 8px;
border-radius: 50%;
transition: all 0.3s ease;
}
#chatUsageMonitor .dot-empty {
background: rgba(239, 68, 68, 0.3);
border: 1px solid ${COLORS.progressLow};
}
#chatUsageMonitor .dot-partial {
background: ${COLORS.progressMed};
}
#chatUsageMonitor .dot-full {
background: ${COLORS.progressHigh};
}
#chatUsageMonitor .dot-exceeded {
background: ${COLORS.progressExceed};
position: relative;
}
#chatUsageMonitor .dot-exceeded::before {
content: '';
position: absolute;
top: 50%;
left: -2px;
right: -2px;
height: 2px;
background: ${COLORS.surface};
transform: rotate(45deg);
}
#chatUsageMonitor .table-header {
font-family: monospace;
color: ${COLORS.white};
font-size: ${STYLE.textSize.xs};
line-height: ${STYLE.lineHeight.xs};
display : grid;
align-items: center;
grid-template-columns: 2fr 1.5fr 1.5fr 2fr;
}
#chatUsageMonitor .model-row {
font-family: monospace;
color: ${COLORS.secondaryText};
transition: color 0.2s ease;
font-size: ${STYLE.textSize.xs};
line-height: ${STYLE.lineHeight.xs};
display : grid;
grid-template-columns: 2fr 1.5fr 1.5fr 2fr;
align-items: center;
}
#chatUsageMonitor .model-row:hover {
color: ${COLORS.yellow};
text-decoration-line: underline;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
/* Container to help position the arrow (pseudo-element) */
#chatUsageMonitor .custom-select {
position: relative;
display: inline-block;
margin-right: 8px;
}
/* Hide the native select arrow and style the dropdown */
#chatUsageMonitor .custom-select select {
-webkit-appearance: none; /* Safari and Chrome */
-moz-appearance: none; /* Firefox */
appearance: none; /* Standard modern browsers */
background-color: transparent;
color: #ffffff;
border: none;
cursor: pointer;
color: ${COLORS.white};
font-size: ${STYLE.textSize.sm};
line-height: ${STYLE.lineHeight.sm};
padding: 2px 5px;
}
/* Style the list of options (when the dropdown is open) */
.custom-select select option {
background: ${COLORS.background};
color: ${COLORS.white};
}
/* Optional: highlight the hovered option in some browsers */
.custom-select select option:hover {
background: ${COLORS.background};
color: ${COLORS.yellow};
text-decoration-line: underline;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
#chatUsageMonitor input {
width: 90%;
padding: ${STYLE.spacing.xs} ${STYLE.spacing.sm};
margin: 0;
border: 1px solid ${COLORS.border};
border-radius: 4px;
background: ${COLORS.surface};
color: ${COLORS.secondaryText};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: ${STYLE.textSize.xs};
line-height: ${STYLE.lineHeight.xs};
transition: all 0.2s ease;
}
#chatUsageMonitor input:focus {
outline: none;
border-color: ${COLORS.yellow};
color: ${COLORS.yellow};
background: rgba(245, 158, 11, 0.1);
}
#chatUsageMonitor input:hover {
border-color: ${COLORS.yellow};
color: ${COLORS.yellow};
}
/* Toast notification for feedback */
#chatUsageMonitor .toast {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: ${COLORS.background};
color: ${COLORS.success};
padding: ${STYLE.spacing.sm} ${STYLE.spacing.md};
border-radius: ${STYLE.borderRadius};
border: 1px solid ${COLORS.success};
opacity: 0;
transition: opacity 0.3s ease;
z-index: 10000;
}
#chatUsageMonitor .toast.show {
opacity: 1;
}
`);
// State Management
const Storage = {
key: "usageData",
get() {
let usageData = GM_getValue(this.key, defaultUsageData);
// Handle migration from older versions
if (!usageData) {
usageData = defaultUsageData;
}
// Add position if missing
if (!usageData.position) {
usageData.position = { x: null, y: null };
}
// Add size if missing
if (!usageData.size) {
usageData.size = { width: 400, height: 500 };
}
// Add minimized state if missing
if (usageData.minimized === undefined) {
usageData.minimized = false;
}
// Add progressType if missing
if (!usageData.progressType) {
usageData.progressType = "bar";
}
// Migrate from count-based to time-based models
Object.entries(usageData.models).forEach(([key, model]) => {
// If the model doesn't have a requests array, create one
if (!Array.isArray(model.requests)) {
model.requests = [];
// If it has a count, create that many requests with current timestamp
// This is an approximation for migration
if (typeof model.count === 'number' && model.count > 0) {
const now = Date.now();
for (let i = 0; i < model.count; i++) {
// Stagger the timestamps slightly for better visualization
model.requests.push({
timestamp: now - (i * 60000) // each request 1 minute apart
});
}
}
// Remove old properties
delete model.count;
delete model.lastUpdate;
}
// Rename dailyLimit to quota if needed
if (model.dailyLimit !== undefined && model.quota === undefined) {
model.quota = model.dailyLimit;
delete model.dailyLimit;
}
// Rename resetFrequency to windowType if needed
if (model.resetFrequency !== undefined && model.windowType === undefined) {
model.windowType = model.resetFrequency;
delete model.resetFrequency;
}
// Ensure windowType is valid
if (!['hour3', 'daily', 'weekly'].includes(model.windowType)) {
model.windowType = 'daily'; // Default to daily
}
});
// Clean up old properties at root level
delete usageData.lastDailyReset;
delete usageData.lastWeeklyReset;
delete usageData.lastReset;
this.set(usageData);
console.debug("[monitor] get usageData:", usageData);
return usageData;
},
set(newData) {
GM_setValue(this.key, newData);
},
update(callback) {
const data = this.get();
callback(data);
this.set(data);
}
};
let usageData = Storage.get();
// Component Functions
function createModelRow(model, modelKey, isSettings = false) {
const row = document.createElement("div");
row.className = "model-row";
if (isSettings) {
return createSettingsModelRow(model, modelKey, row);
}
return createUsageModelRow(model, modelKey, row);
}
function createSettingsModelRow(model, modelKey, row) {
// Model ID cell
const keyLabel = document.createElement("div");
keyLabel.textContent = modelKey;
row.appendChild(keyLabel);
// Display Name input cell
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.value = model.displayName || modelKey;
nameInput.placeholder = "Display Name";
nameInput.dataset.modelKey = modelKey;
nameInput.dataset.field = "displayName";
row.appendChild(nameInput);
// Quota input cell
const quotaInput = document.createElement("input");
quotaInput.type = "number";
quotaInput.value = model.quota;
quotaInput.placeholder = "quota";
quotaInput.dataset.modelKey = modelKey;
quotaInput.dataset.field = "quota";
row.appendChild(quotaInput);
// Window Type Select
const windowSelect = document.createElement("select");
windowSelect.dataset.modelKey = modelKey;
windowSelect.dataset.field = "windowType";
const hour3Option = document.createElement("option");
hour3Option.value = "hour3";
hour3Option.textContent = "3 Hour Window";
const dailyOption = document.createElement("option");
dailyOption.value = "daily";
dailyOption.textContent = "24 Hour Window";
const weeklyOption = document.createElement("option");
weeklyOption.value = "weekly";
weeklyOption.textContent = "7 Day Window";
windowSelect.appendChild(hour3Option);
windowSelect.appendChild(dailyOption);
windowSelect.appendChild(weeklyOption);
// Set the current value
windowSelect.value = model.windowType || "daily";
const controlsContainer = document.createElement("div");
controlsContainer.style.display = "flex";
controlsContainer.style.alignItems = "center";
controlsContainer.style.gap = "4px";
controlsContainer.appendChild(windowSelect);
// Delete button
const delBtn = document.createElement("button");
delBtn.className = "btn delete-btn";
delBtn.textContent = "Delete";
delBtn.dataset.modelKey = modelKey;
delBtn.addEventListener("click", () => handleDeleteModel(modelKey));
controlsContainer.appendChild(delBtn);
row.appendChild(controlsContainer);
return row;
}
function createUsageModelRow(model, modelKey) {
const now = Date.now();
// Filter requests to only include those within the time window
const windowDuration = TIME_WINDOWS[model.windowType];
const activeRequests = model.requests.filter(req =>
now - req.timestamp < windowDuration
);
const count = activeRequests.length;
let lastRequestTime = count > 0 ?
formatTimeAgo(Math.max(...activeRequests.map(req => req.timestamp))) :
"never";
// Calculate time until oldest request expires (window end time)
let windowEndInfo = "";
if (count > 0) {
const oldestActiveTimestamp = Math.min(...activeRequests.map(req => req.timestamp));
const windowEnd = getWindowEnd(oldestActiveTimestamp, model.windowType);
if (windowEnd > now) {
windowEndInfo = `Window resets in: ${formatTimeLeft(windowEnd)}`;
}
}
const row = document.createElement("div");
row.className = "model-row";
// Model Name cell with window type badge
const modelNameContainer = document.createElement("div");
modelNameContainer.style.display = "flex";
modelNameContainer.style.alignItems = "center";
const modelName = document.createElement("span");
modelName.textContent = model.displayName;
modelNameContainer.appendChild(modelName);
// Add window type badge
const windowBadge = document.createElement("span");
windowBadge.className = `window-badge ${model.windowType}`;
// Display badge based on window type
if (model.windowType === "hour3") {
windowBadge.textContent = "3h";
} else if (model.windowType === "daily") {
windowBadge.textContent = "24h";
} else {
windowBadge.textContent = "7d";
}
windowBadge.title = `${model.windowType === "hour3" ? "3 hour" :
model.windowType === "daily" ? "24 hour" : "7 day"} sliding window`;
modelNameContainer.appendChild(windowBadge);
row.appendChild(modelNameContainer);
// Last Request Time cell
const lastUpdateValue = document.createElement("div");
lastUpdateValue.className = "request-time";
lastUpdateValue.textContent = lastRequestTime;
row.appendChild(lastUpdateValue);
// Usage cell
const usageValue = document.createElement("div");
// If model is gpt-4o, show numeric quota (not a question mark)
if (modelKey === "gpt-4o") {
usageValue.innerHTML = `${count} / ${model.quota}`;
} else {
const quotaDisplay = model.quota > 0 ? model.quota : "∞";
usageValue.textContent = `${count} / ${quotaDisplay}`;
}
// Add window info if available
if (windowEndInfo) {
const windowInfoEl = document.createElement("div");
windowInfoEl.className = "window-info";
windowInfoEl.textContent = windowEndInfo;
usageValue.appendChild(windowInfoEl);
}
row.appendChild(usageValue);
// Progress Bar cell
const progressCell = document.createElement("div");
// For all models with quota
if (model.quota > 0) {
const usagePercent = count / model.quota;
if (usageData.progressType === "dots") {
// Dot-based progress implementation
const dotContainer = document.createElement("div");
dotContainer.className = "dot-progress";
const totalDots = 8;
for (let i = 0; i < totalDots; i++) {
const dot = document.createElement("div");
dot.className = "dot";
const dotThreshold = (i + 1) / totalDots;
if (usagePercent >= 1) {
dot.classList.add("dot-exceeded");
} else if (usagePercent >= dotThreshold) {
dot.classList.add("dot-full");
} else if (usagePercent >= dotThreshold - 0.1) {
dot.classList.add("dot-partial");
} else {
dot.classList.add("dot-empty");
}
dotContainer.appendChild(dot);
}
progressCell.appendChild(dotContainer);
} else {
// Enhanced progress bar implementation
const progressContainer = document.createElement("div");
progressContainer.className = "progress-container";
const progressBar = document.createElement("div");
progressBar.className = "progress-bar";
if (usagePercent > 1) {
progressBar.classList.add("exceeded");
} else if (usagePercent < 0.3) {
progressBar.classList.add("low-usage");
}
progressBar.style.width = `${Math.min(usagePercent * 100, 100)}%`;
progressContainer.appendChild(progressBar);
progressCell.appendChild(progressContainer);
}
} else {
progressCell.style.width = `100%`;
}
row.appendChild(progressCell);
return row;
}
// Event Handlers
function handleDeleteModel(modelKey) {
if (confirm(`Delete mapping for model "${modelKey}"?`)) {
delete usageData.models[modelKey];
Storage.set(usageData);
updateUI();
showToast(`Model "${modelKey}" deleted.`);
}
}
function animateText(el, config) {
const animator = new TextScrambler(el, {...config});
animator.initialize();
animator.start();
}
// UI Updates
function updateUI() {
const usageContent = document.getElementById("usageContent");
const settingsContent = document.getElementById("settingsContent");
if (usageContent) {
console.debug("[monitor] update usage");
updateUsageContent(usageContent);
animateText(usageContent, { duration: 500, delay: 0, reverse: false, absolute: false, pointerEvents: true });
}
if (settingsContent) {
console.debug("[monitor] update setting");
updateSettingsContent(settingsContent);
animateText(settingsContent, { duration: 500, delay: 0, reverse: false, absolute: false, pointerEvents: true });
}
}
let sortDescending = true;
function updateUsageContent(container) {
container.innerHTML = "";
// Sliding window explanation
const infoSection = document.createElement("div");
infoSection.className = "reset-info";
infoSection.innerHTML = `<b>Sliding Window Tracking:</b>`;
const windowTypes = document.createElement("div");
windowTypes.style.display = "flex";
windowTypes.style.justifyContent = "space-between";
windowTypes.style.marginTop = "4px";
windowTypes.innerHTML = `
<span><span class="window-badge hour3">3h</span> 3 hour window</span>
<span><span class="window-badge daily">24h</span> 24 hour window</span>
<span><span class="window-badge weekly">7d</span> 7 day window</span>
`;
infoSection.appendChild(windowTypes);
container.appendChild(infoSection);
// Table Header Row
const tableHeader = document.createElement("div");
tableHeader.className = "table-header";
// Header cells
const modelNameHeader = document.createElement("div");
modelNameHeader.textContent = "Model Name";
tableHeader.appendChild(modelNameHeader);
const lastUpdateHeader = document.createElement("div");
lastUpdateHeader.textContent = "Last Used";
tableHeader.appendChild(lastUpdateHeader);
const usageHeader = document.createElement("div");
usageHeader.textContent = sortDescending ? "Usage ↓" : "Usage ↑";
usageHeader.style.cursor = "pointer";
usageHeader.addEventListener("click", () => {
sortDescending = !sortDescending;
updateUsageContent(container);
});
tableHeader.appendChild(usageHeader);
const progressHeader = document.createElement("div");
progressHeader.textContent = "Progress";
tableHeader.appendChild(progressHeader);
container.appendChild(tableHeader);
// Calculate active counts for all models
const now = Date.now();
const modelCounts = Object.entries(usageData.models).map(([key, model]) => {
const windowDuration = TIME_WINDOWS[model.windowType];
const activeCount = model.requests.filter(req =>
now - req.timestamp < windowDuration
).length;
return { key, model, activeCount };
});
// Sort models by usage count
const sortedModels = modelCounts.sort(
(a, b) => sortDescending ? b.activeCount - a.activeCount : a.activeCount - b.activeCount
);
// Create a row for each model
sortedModels.forEach(({ key, model }) => {
const row = createUsageModelRow(model, key);
container.appendChild(row);
});
if (sortedModels.length === 0) {
const emptyState = document.createElement("div");
emptyState.style.textAlign = "center";
emptyState.style.color = COLORS.secondaryText;
emptyState.style.padding = STYLE.spacing.lg;
emptyState.textContent = "No models configured. Add some in Settings.";
container.appendChild(emptyState);
}
}
function updateSettingsContent(container) {
container.innerHTML = "";
const info = document.createElement("p");
info.innerHTML = `Configure model mappings and quotas:<br>
<span style="color:${COLORS.secondaryText}; font-size:${STYLE.textSize.xs};">
Uses sliding time windows like OpenAI (counts usage in last N hours)
</span>`;
info.style.fontSize = STYLE.textSize.md;
info.style.fontSize = STYLE.lineHeight.md;
info.style.color = COLORS.text;
container.appendChild(info);
// Add table header for settings
const tableHeader = document.createElement("div");
tableHeader.className = "table-header";
tableHeader.style.gridTemplateColumns = "1.5fr 1.5fr 1fr 2fr";
const idHeader = document.createElement("div");
idHeader.textContent = "Model ID";
tableHeader.appendChild(idHeader);
const nameHeader = document.createElement("div");
nameHeader.textContent = "Display Name";
tableHeader.appendChild(nameHeader);
const quotaHeader = document.createElement("div");
quotaHeader.textContent = "Quota";
tableHeader.appendChild(quotaHeader);
const actionHeader = document.createElement("div");
actionHeader.textContent = "Window/Action";
tableHeader.appendChild(actionHeader);
container.appendChild(tableHeader);
// Update model rows style
GM_addStyle(`
#settingsContent .table-header,
#settingsContent .model-row {
grid-template-columns: 1.5fr 1.5fr 1fr 2fr;
}
`);
Object.entries(usageData.models).forEach(([modelKey, model]) => {
const row = createModelRow(model, modelKey, true);
container.appendChild(row);
});
// Add new model button
const addBtn = document.createElement("button");
addBtn.className = "btn";
addBtn.textContent = "Add Model Mapping";
addBtn.addEventListener("click", () => {
const newModelID = prompt('Enter new model internal ID (e.g., "o3-mini")');
if (!newModelID) return;
if (usageData.models[newModelID]) {
alert("Model mapping already exists.");
return;
}
usageData.models[newModelID] = {
displayName: newModelID,
requests: [],
quota: 50,
windowType: "daily"
};
Storage.set(usageData);
updateUI();
});
container.appendChild(addBtn);
// Save settings button
const saveBtn = document.createElement("button");
saveBtn.className = "btn";
saveBtn.textContent = "Save Settings";
saveBtn.style.marginLeft = STYLE.spacing.sm;
saveBtn.addEventListener("click", () => {
const inputs = container.querySelectorAll("input, select");
let hasChanges = false;
inputs.forEach((input) => {
const modelKey = input.dataset.modelKey;
const field = input.dataset.field;
if (!modelKey || !usageData.models[modelKey]) return;
if (field === "displayName") {
const newDisplayName = input.value.trim();
if (newDisplayName && newDisplayName !== usageData.models[modelKey].displayName) {
usageData.models[modelKey].displayName = newDisplayName;
hasChanges = true;
}
} else if (field === "quota") {
const newQuota = parseInt(input.value, 10);
if (!isNaN(newQuota) && newQuota !== usageData.models[modelKey].quota) {
usageData.models[modelKey].quota = newQuota;
hasChanges = true;
}
} else if (field === "windowType") {
const newWindowType = input.value;
if (newWindowType && newWindowType !== usageData.models[modelKey].windowType) {
usageData.models[modelKey].windowType = newWindowType;
hasChanges = true;
}
}
});
if (hasChanges) {
Storage.set(usageData);
updateUI();
showToast("Settings saved successfully.");
} else {
showToast("No changes detected.", "warning");
}
});
container.appendChild(saveBtn);
// Clear history button
const clearBtn = document.createElement("button");
clearBtn.className = "btn";
clearBtn.textContent = "Clear History";
clearBtn.style.marginLeft = STYLE.spacing.sm;
clearBtn.addEventListener("click", () => {
if (confirm("Clear usage history for all models?")) {
Object.values(usageData.models).forEach(model => {
model.requests = [];
});
Storage.set(usageData);
updateUI();
showToast("Usage history cleared for all models.");
}
});
container.appendChild(clearBtn);
// Reset all button (completely resets everything to defaults)
const resetAllBtn = document.createElement("button");
resetAllBtn.className = "btn";
resetAllBtn.textContent = "Reset Everything";
resetAllBtn.style.marginLeft = STYLE.spacing.sm;
resetAllBtn.style.color = COLORS.danger;
resetAllBtn.addEventListener("click", () => {
if (confirm("WARNING: This will reset EVERYTHING to defaults including all model configurations. Continue?")) {
Storage.set(defaultUsageData);
usageData = defaultUsageData;
updateUI();
showToast("Everything has been reset to defaults.", "warning");
}
});
container.appendChild(resetAllBtn);
// Progress type selector & additional options
const optionsContainer = document.createElement("div");
optionsContainer.style.marginTop = STYLE.spacing.md;
optionsContainer.style.display = "flex";
optionsContainer.style.flexDirection = "column";
optionsContainer.style.gap = "8px";
// Progress type selector
const progressSelectContainer = document.createElement("div");
progressSelectContainer.className = "custom-select";
const progressTypeLabel = document.createElement("span");
progressTypeLabel.textContent = "Progress style: ";
progressTypeLabel.style.color = COLORS.secondaryText;
progressSelectContainer.appendChild(progressTypeLabel);
const progressTypeSelect = document.createElement("select");
progressTypeSelect.innerHTML = `
<option value="dots">Dots</option>
<option value="bar">Bar</option>
`;
progressTypeSelect.value = usageData.progressType || "bar";
progressTypeSelect.addEventListener('change', () => {
usageData.progressType = progressTypeSelect.value;
Storage.set(usageData);
updateUI();
console.debug('[monitor] progress type:', progressTypeSelect.value);
});
progressSelectContainer.appendChild(progressTypeSelect);
optionsContainer.appendChild(progressSelectContainer);
container.appendChild(optionsContainer);
}
// Model Usage Tracking
function recordModelUsage(modelId) {
// Get fresh data
usageData = Storage.get();
// Clean up expired requests to save storage
cleanupExpiredRequests();
if (!usageData.models[modelId]) {
console.debug(`[monitor] No mapping found for model "${modelId}". Creating new entry.`);
usageData.models[modelId] = {
displayName: modelId,
requests: [],
quota: 50,
windowType: "daily" // Default to daily
};
}
// Add new request with current timestamp
usageData.models[modelId].requests.push({
timestamp: Date.now()
});
Storage.set(usageData);
updateUI();
}
// Cleanup old requests that are no longer relevant for any window type
function cleanupExpiredRequests() {
const now = Date.now();
const maxWindow = TIME_WINDOWS.weekly; // Longest time window
Object.values(usageData.models).forEach(model => {
// Keep only requests within the longest possible window
model.requests = model.requests.filter(req =>
now - req.timestamp < maxWindow
);
});
}
// Toast notification function
function showToast(message, type = 'success') {
const container = document.getElementById('chatUsageMonitor');
if (!container) return;
// Remove any existing toast
const existingToast = container.querySelector('.toast');
if (existingToast) {
existingToast.remove();
}
// Create new toast
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
// Set color based on type
if (type === 'error') {
toast.style.color = COLORS.danger;
toast.style.borderColor = COLORS.danger;
} else if (type === 'warning') {
toast.style.color = COLORS.warning;
toast.style.borderColor = COLORS.warning;
}
container.appendChild(toast);
// Show toast
setTimeout(() => {
toast.classList.add('show');
}, 10);
// Hide toast after 3 seconds
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
}
// Improved draggable functionality that supports both vertical and horizontal movement
function setupDraggable(element) {
let isDragging = false;
let startX, startY, origLeft, origTop;
// Make header draggable
const handle = element.querySelector('header');
if (handle) {
handle.addEventListener('mousedown', startDrag);
}
// Make minimized panel draggable from anywhere
element.addEventListener('mousedown', (e) => {
if (element.classList.contains('minimized')) {
startDrag(e);
}
});
function startDrag(e) {
// Ignore clicks on minimize button or other controls
if (e.target.classList.contains('minimize-btn') ||
e.target.tagName === 'BUTTON' ||
e.target.tagName === 'INPUT' ||
e.target.tagName === 'SELECT') {
return;
}
isDragging = false; // Start as false until we move enough
startX = e.clientX;
startY = e.clientY;
// Get current position
const rect = element.getBoundingClientRect();
origLeft = rect.left;
origTop = rect.top;
// Add movement handlers
document.addEventListener('mousemove', handleDrag);
document.addEventListener('mouseup', stopDrag);
e.preventDefault();
}
function handleDrag(e) {
// Calculate new position
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// Only consider it a drag if moved more than 5px
if (!isDragging && (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5)) {
isDragging = true;
console.log("[monitor] Started dragging");
}
if (isDragging) {
// Apply boundary constraints
const rect = element.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
const newLeft = Math.min(Math.max(0, origLeft + deltaX), maxX);
const newTop = Math.min(Math.max(0, origTop + deltaY), maxY);
// Update position using important to override defaults
element.style.setProperty('left', `${newLeft}px`, 'important');
element.style.setProperty('top', `${newTop}px`, 'important');
element.style.setProperty('right', 'auto', 'important');
element.style.setProperty('bottom', 'auto', 'important');
e.preventDefault();
}
}
function stopDrag(e) {
document.removeEventListener('mousemove', handleDrag);
document.removeEventListener('mouseup', stopDrag);
if (isDragging) {
console.log("[monitor] Stopped dragging");
// Save position
const newLeft = parseInt(element.style.left);
const newTop = parseInt(element.style.top);
Storage.update(data => {
data.position = {
x: newLeft,
y: newTop
};
});
// Give time for real click to be ignored
setTimeout(() => {
isDragging = false;
}, 200);
// Prevent click
e.preventDefault();
e.stopPropagation();
}
}
}
// UI Creation
function createMonitorUI() {
if (document.getElementById("chatUsageMonitor")) return;
const container = document.createElement("div");
container.id = "chatUsageMonitor";
// Apply minimized state if needed
if (usageData.minimized) {
container.classList.add("minimized");
}
// Apply custom size if set
if (usageData.size.width && usageData.size.height && !usageData.minimized) {
container.style.width = `${usageData.size.width}px`;
container.style.height = `${usageData.size.height}px`;
}
// Set saved position if available
if (usageData.position.x !== null && usageData.position.y !== null) {
const maxX = window.innerWidth - 400;
const maxY = window.innerHeight - 500;
const x = Math.min(Math.max(0, usageData.position.x), maxX);
const y = Math.min(Math.max(0, usageData.position.y), maxY);
container.style.setProperty('left', `${x}px`, 'important');
container.style.setProperty('top', `${y}px`, 'important');
container.style.setProperty('right', 'auto', 'important');
container.style.setProperty('bottom', 'auto', 'important');
} else {
// 使用新的默认位置
container.style.setProperty('left', STYLE.spacing.lg, 'important');
container.style.setProperty('bottom', '100px', 'important');
container.style.setProperty('right', 'auto', 'important');
container.style.setProperty('top', 'auto', 'important');
}
// Create header with tabs
const header = document.createElement("header");
// Add minimize button
const minimizeBtn = document.createElement("div");
minimizeBtn.className = "minimize-btn";
minimizeBtn.innerHTML = "−";
minimizeBtn.title = "Minimize monitor";
minimizeBtn.addEventListener("click", (e) => {
e.stopPropagation(); // 阻止事件冒泡
console.log("[monitor] Minimize button clicked");
container.classList.add("minimized");
// Save minimized state
Storage.update(data => {
data.minimized = true;
});
});
header.appendChild(minimizeBtn);
// Create tab buttons with proper spacing
const usageTabBtn = document.createElement("button");
usageTabBtn.innerHTML = `<span>Usage</span>`;
usageTabBtn.classList.add("active");
const settingsTabBtn = document.createElement("button");
settingsTabBtn.innerHTML = `<span>Settings</span>`;
header.appendChild(usageTabBtn);
header.appendChild(settingsTabBtn);
container.appendChild(header);
// Create content panels
const usageContent = document.createElement("div");
usageContent.className = "content";
usageContent.id = "usageContent";
container.appendChild(usageContent);
const settingsContent = document.createElement("div");
settingsContent.className = "content";
settingsContent.id = "settingsContent";
settingsContent.style.display = "none";
container.appendChild(settingsContent);
// Add tab switching logic
usageTabBtn.addEventListener("click", () => {
usageTabBtn.classList.add("active");
settingsTabBtn.classList.remove("active");
usageContent.style.display = "";
settingsContent.style.display = "none";
});
settingsTabBtn.addEventListener("click", () => {
settingsTabBtn.classList.add("active");
usageTabBtn.classList.remove("active");
settingsContent.style.display = "";
usageContent.style.display = "none";
});
// Add restore functionality when clicking minimized monitor
container.addEventListener("click", (e) => {
if (container.classList.contains("minimized")) {
console.log("[monitor] Clicked on minimized container, restoring...");
container.classList.remove("minimized");
// When restored, apply saved size
if (usageData.size.width && usageData.size.height) {
container.style.width = `${usageData.size.width}px`;
container.style.height = `${usageData.size.height}px`;
}
// Save state
Storage.update(data => {
data.minimized = false;
});
e.stopPropagation();
}
});
document.body.appendChild(container);
setupDraggable(container);
updateUI();
// Save size when resizing
const resizeObserver = new ResizeObserver((entries) => {
if (!container.classList.contains('minimized')) {
const width = container.offsetWidth;
const height = container.offsetHeight;
if (width > 50 && height > 50) {
Storage.update(data => {
data.size = { width, height };
});
}
}
});
resizeObserver.observe(container);
// Update UI periodically
setInterval(updateUI, 60000); // Every minute
}
// Fetch Interception
const target_window = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
const originalFetch = target_window.fetch;
target_window.fetch = new Proxy(originalFetch, {
apply: async function (target, thisArg, args) {
const response = await target.apply(thisArg, args);
try {
const [requestInfo, requestInit] = args;
const fetchUrl = typeof requestInfo === "string" ? requestInfo : requestInfo?.href;
if (requestInit?.method === "POST" && fetchUrl?.endsWith("/conversation")) {
const bodyText = requestInit.body;
const bodyObj = JSON.parse(bodyText);
if (bodyObj?.model) {
console.debug("[monitor] Detected model usage:", bodyObj.model);
recordModelUsage(bodyObj.model);
}
}
} catch (error) {
console.warn("[monitor] Failed to process request:", error);
}
return response;
},
});
// Initialize
function initialize() {
cleanupExpiredRequests();
createMonitorUI();
}
// Setup observers and event listeners
if (document.readyState === "loading") {
target_window.addEventListener("DOMContentLoaded", initialize);
} else {
setTimeout(initialize, 500);
}
// Observer for dynamic content changes
const observer = new MutationObserver(() => {
if (!document.getElementById("chatUsageMonitor")) {
setTimeout(initialize, 300);
}
});
observer.observe(document.documentElement || document.body, {
childList: true,
subtree: true,
});
// Handle navigation events
window.addEventListener("popstate", () => setTimeout(initialize, 300));
// Initialize immediately
setTimeout(initialize, 300);
})();