// ==UserScript== // @name ChatGPT用量统计 // @namespace https://github.com/tizee/tampermonkey-chatgpt-model-usage-monitor // @version 2.0.0 // @description 优雅的 ChatGPT 模型调用量实时统计 // @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 // @downloadURL none // ==/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._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 = `Sliding Window Tracking:`; const windowTypes = document.createElement("div"); windowTypes.style.display = "flex"; windowTypes.style.justifyContent = "space-between"; windowTypes.style.marginTop = "4px"; windowTypes.innerHTML = ` 3h 3 hour window 24h 24 hour window 7d 7 day window `; 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:
Uses sliding time windows like OpenAI (counts usage in last N hours) `; 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 = ` `; 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 = `Usage`; usageTabBtn.classList.add("active"); const settingsTabBtn = document.createElement("button"); settingsTabBtn.innerHTML = `Settings`; 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); })();