// ==UserScript==
// @name ChatGPT Realtime Model Switcher: 4o-mini, o4-mini, o3 and more!
// @namespace http://tampermonkey.net/
// @version 3.33.33
// @description Allowing you to switch models during a single conversation, and highlight responses by color based on the model generating them
// @match *://chatgpt.com/*
// @author sickn33 (UI enhancements by Athena) (https://greasyfork.org/en/users/99408-d0gkiller87) (https://greasyfork.org/en/scripts/514276-chatgpt-realtime-model-switcher-4o-mini-o4-mini-o3-and-more)
// @license MIT
// @grant unsafeWindow
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @grant GM_registerMenuCommand
// @grant GM.registerMenuCommand
// @grant GM.unregisterMenuCommand
// @run-at document-idle
// @icon https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @downloadURL https://update.greasyfork.org/scripts/514276/ChatGPT%20Realtime%20Model%20Switcher%3A%204o-mini%2C%20o4-mini%2C%20o3%20and%20more%21.user.js
// @updateURL https://update.greasyfork.org/scripts/514276/ChatGPT%20Realtime%20Model%20Switcher%3A%204o-mini%2C%20o4-mini%2C%20o3%20and%20more%21.meta.js
// ==/UserScript==
(async function() {
'use strict';
function injectStyle( style, isDisabled = false ) {
const styleNode = document.createElement( 'style' );
styleNode.type = 'text/css';
styleNode.textContent = style;
document.head.appendChild( styleNode );
styleNode.disabled = isDisabled;
return styleNode;
}
const PlanType = Object.freeze({
free: 0,
plus: 1,
pro : 2
});
class ModelSwitcher {
constructor() {
// Cache per performance
this.cache = new Map();
this.throttledCallbacks = new Map();
this.debouncedCallbacks = new Map();
// Pool di eventi per ridurre memory allocation
this.eventPool = [];
this.maxPoolSize = 10;
}
// Utility per throttling - limita frequenza esecuzione
throttle(func, limit, key) {
if (this.throttledCallbacks.has(key)) {
return this.throttledCallbacks.get(key);
}
let inThrottle;
const throttledFunc = (...args) => {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
this.throttledCallbacks.set(key, throttledFunc);
return throttledFunc;
}
// Utility per debouncing - ritarda esecuzione fino a pausa
debounce(func, delay, key) {
if (this.debouncedCallbacks.has(key)) {
return this.debouncedCallbacks.get(key);
}
let timeoutId;
const debouncedFunc = (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
this.debouncedCallbacks.set(key, debouncedFunc);
return debouncedFunc;
}
// Cache intelligente con TTL
setCache(key, value, ttl = 300000) { // 5 minuti default
this.cache.set(key, {
value,
timestamp: Date.now(),
ttl
});
}
getCache(key) {
const cached = this.cache.get(key);
if (!cached) return null;
if (Date.now() - cached.timestamp > cached.ttl) {
this.cache.delete(key);
return null;
}
return cached.value;
}
// Cleanup cache periodico
startCacheCleanup() {
const cleanup = () => {
const now = Date.now();
for (const [key, data] of this.cache.entries()) {
if (now - data.timestamp > data.ttl) {
this.cache.delete(key);
}
}
};
// Cleanup ogni 5 minuti
setInterval(cleanup, 300000);
}
// Pool di oggetti event per ridurre GC
getEventFromPool() {
return this.eventPool.length > 0 ? this.eventPool.pop() : {};
}
returnEventToPool(event) {
if (this.eventPool.length < this.maxPoolSize) {
// Reset object
Object.keys(event).forEach(key => delete event[key]);
this.eventPool.push(event);
}
}
getPlanType() {
// Check cache first
const cached = this.getCache('planType');
if (cached) return cached;
// Ottimizzazione: cerca solo nei script con contenuto rilevante
const scripts = document.querySelectorAll('script');
const planTypeRegex = /\\"planType\\"\s*,\s*\\"(\w+?)\\"/ ;
for (let i = 0; i < scripts.length; i++) {
const scriptContent = scripts[i].innerHTML;
// Skip script vuoti o troppo piccoli
if (scriptContent.length < 100) continue;
// Cerca solo se contiene "planType"
if (!scriptContent.includes('planType')) continue;
const match = planTypeRegex.exec(scriptContent);
if (match && match[1]) {
const planType = match[1];
// Cache per 10 minuti
this.setCache('planType', planType, 600000);
return planType;
}
}
// Fallback con cache
this.setCache('planType', 'free', 600000);
return 'free';
}
async init() {
this.model = await GM.getValue( 'model', 'auto' );
this.buttons = {};
this.offsetX = 0;
this.offsetY = 0;
this.isDragging = false;
this.shouldCancelClick = false;
this.modelSelector = null;
this.isMenuVisible = await GM.getValue( 'isMenuVisible', true );
this.isMenuVisibleCommandId = null;
this.modelHighlightStyleNode = null;
this.isModelHighlightEnabled = await GM.getValue( 'isModelHighlightEnabled', true );
this.isModelHighlightEnabledCommandId = null;
this.isMenuVertical = await GM.getValue( 'isMenuVertical', true );
this.isMenuVerticalCommandId = null;
this.conversationUrlRegex = new RegExp( /https:\/\/chatgpt\.com\/backend-api\/.*conversation/ );
const planType = PlanType[ this.getPlanType() ];
const models = [
[ PlanType.pro, "o1-pro", "o1-pro" ],
[ PlanType.plus, "o3", "o3" ],
[ PlanType.free, "o4-mini", "o4-mini" ],
[ PlanType.plus, "o4-mini-high", "o4-mini-high" ],
[ PlanType.free, "gpt-3.5", "gpt-3-5" ],
[ PlanType.free, "4o-mini", "gpt-4o-mini" ],
[ PlanType.free, "4.1-mini", "gpt-4-1-mini" ],
[ PlanType.free, "gpt-4o", "gpt-4o" ],
[ PlanType.plus, "gpt-4.1", "gpt-4-1" ],
[ PlanType.plus, "gpt-4.5", "gpt-4-5" ],
[ PlanType.free, "default", "auto" ],
];
this.availableModels = {};
for ( const [ minimumPlan, modelName, modelValue ] of models ) {
if ( planType >= minimumPlan ) {
this.availableModels[modelName] = modelValue;
}
}
}
hookFetch() {
const originalFetch = unsafeWindow.fetch;
unsafeWindow.fetch = async ( resource, config = {} ) => {
if (
typeof resource === 'string' &&
resource.match( this.conversationUrlRegex ) &&
config.method === 'POST' &&
config.headers &&
config.headers['Content-Type'] === 'application/json' &&
config.body &&
this.model !== 'auto'
) {
const body = JSON.parse( config.body );
body.model = this.model;
config.body = JSON.stringify( body );
}
return originalFetch( resource, config );
};
}
injectToggleButtonStyle() {
// UI completamente ridisegnata per allinearsi perfettamente a ChatGPT
let style = `
:root {
/* Palette colori ChatGPT ufficiale */
--chatgpt-text-primary: #2d333a;
--chatgpt-text-secondary: #6b7280;
--chatgpt-text-tertiary: #9ca3af;
--chatgpt-surface-primary: #ffffff;
--chatgpt-surface-secondary: #f7f7f8;
--chatgpt-surface-tertiary: #ececf1;
--chatgpt-border-light: #d1d5db;
--chatgpt-border-medium: #9ca3af;
--chatgpt-accent-blue: #10a37f;
--chatgpt-accent-hover: #0d8a6b;
--chatgpt-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--chatgpt-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--chatgpt-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
/* Dark mode - ChatGPT style */
--chatgpt-dark-text-primary: #ececf1;
--chatgpt-dark-text-secondary: #c5c5d2;
--chatgpt-dark-text-tertiary: #8e8ea0;
--chatgpt-dark-surface-primary: #212121;
--chatgpt-dark-surface-secondary: #2f2f2f;
--chatgpt-dark-surface-tertiary: #424242;
--chatgpt-dark-border-light: #4d4d4f;
--chatgpt-dark-border-medium: #565869;
--chatgpt-dark-accent-blue: #19c37d;
--chatgpt-dark-accent-hover: #15a670;
}
@media (prefers-color-scheme: dark) {
:root {
--chatgpt-text-primary: var(--chatgpt-dark-text-primary);
--chatgpt-text-secondary: var(--chatgpt-dark-text-secondary);
--chatgpt-text-tertiary: var(--chatgpt-dark-text-tertiary);
--chatgpt-surface-primary: var(--chatgpt-dark-surface-primary);
--chatgpt-surface-secondary: var(--chatgpt-dark-surface-secondary);
--chatgpt-surface-tertiary: var(--chatgpt-dark-surface-tertiary);
--chatgpt-border-light: var(--chatgpt-dark-border-light);
--chatgpt-border-medium: var(--chatgpt-dark-border-medium);
--chatgpt-accent-blue: var(--chatgpt-dark-accent-blue);
--chatgpt-accent-hover: var(--chatgpt-dark-accent-hover);
}
}
#model-selector {
position: absolute;
display: flex;
flex-direction: column;
gap: 4px;
cursor: grab;
background: var(--chatgpt-surface-primary);
border: 1px solid var(--chatgpt-border-light);
border-radius: 12px;
padding: 8px;
box-shadow: var(--chatgpt-shadow-lg);
font-family: 'Söhne', 'ui-sans-serif', 'system-ui', '-apple-system', 'Segoe UI', sans-serif;
z-index: 10000;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
min-width: 120px;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
#model-selector:hover {
box-shadow: var(--chatgpt-shadow-lg), 0 0 0 1px var(--chatgpt-border-medium);
}
#model-selector.horizontal {
flex-direction: row;
flex-wrap: wrap;
}
#model-selector.hidden {
opacity: 0;
pointer-events: none;
transform: scale(0.95);
}
#model-selector:active {
cursor: grabbing;
}
/* Stile bottoni - identico a ChatGPT */
#model-selector button {
background: var(--chatgpt-surface-secondary);
border: 1px solid var(--chatgpt-border-light);
color: var(--chatgpt-text-primary);
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
line-height: 1.25;
user-select: none;
border-radius: 8px;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
min-width: 60px;
text-align: center;
position: relative;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-family: inherit;
}
#model-selector button:hover {
background: var(--chatgpt-surface-tertiary);
border-color: var(--chatgpt-border-medium);
transform: translateY(-1px);
box-shadow: var(--chatgpt-shadow-sm);
}
#model-selector button:active {
transform: translateY(0);
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.1);
}
#model-selector button:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(16, 163, 127, 0.1);
border-color: var(--chatgpt-accent-blue);
}
/* Stato selezionato - stile ChatGPT */
#model-selector button.selected {
background: var(--chatgpt-accent-blue);
border-color: var(--chatgpt-accent-blue);
color: white;
font-weight: 600;
box-shadow: var(--chatgpt-shadow-md), inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
position: relative;
}
#model-selector button.selected::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, transparent 100%);
border-radius: inherit;
pointer-events: none;
}
#model-selector button.selected:hover {
background: var(--chatgpt-accent-hover);
border-color: var(--chatgpt-accent-hover);
transform: translateY(-1px);
box-shadow: var(--chatgpt-shadow-lg), inset 0 1px 0 0 rgba(255, 255, 255, 0.1);
}
#model-selector button.selected:active {
transform: translateY(0);
box-shadow: var(--chatgpt-shadow-md), inset 0 2px 4px 0 rgba(0, 0, 0, 0.2);
}
/* Indicatori modello colorati - sottili come ChatGPT */
#model-selector button.selected::after {
content: '';
position: absolute;
top: 2px;
right: 2px;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
}
/* Animazioni smooth */
@keyframes modelSelectorAppear {
from {
opacity: 0;
transform: scale(0.9) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
#model-selector:not(.hidden) {
animation: modelSelectorAppear 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Responsive design */
@media (max-width: 768px) {
#model-selector {
padding: 6px;
gap: 3px;
border-radius: 10px;
}
#model-selector button {
padding: 6px 10px;
font-size: 12px;
min-width: 50px;
}
}
/* Accessibility */
@media (prefers-reduced-motion: reduce) {
#model-selector,
#model-selector button {
transition: none;
}
#model-selector:not(.hidden) {
animation: none;
}
}
/* Focus ring per navigazione keyboard */
#model-selector:focus-within {
box-shadow: var(--chatgpt-shadow-lg), 0 0 0 3px rgba(16, 163, 127, 0.1);
}
`;
injectStyle( style );
}
refreshButtons() {
for ( const [ modelKey, button ] of Object.entries( this.buttons ) ) { // modelKey è come "btn-gpt-4o"
const modelValue = modelKey.substring(4); // Estrae "gpt-4o" da "btn-gpt-4o"
const isSelected = modelValue === this.model;
// Gestisce la classe selected
button.classList.toggle( 'selected', isSelected );
// Mantiene sempre la classe del modello per il targeting CSS
button.classList.add(`btn-${modelValue}`);
// Aggiunge attributo per accessibility
button.setAttribute('aria-pressed', isSelected.toString());
// Aggiorna il tooltip con feedback più ricco
if (isSelected) {
button.title = `Modello attivo: ${modelValue}`;
} else {
button.title = `Passa a ${modelValue}`;
}
}
}
async reloadMenuVisibleToggle() {
if (this.isMenuVisibleCommandId) {
try { await GM.unregisterMenuCommand(this.isMenuVisibleCommandId); } catch (e) { console.warn("Failed to unregister menu command for visibility:", e); }
}
this.isMenuVisibleCommandId = await GM.registerMenuCommand(
`${ this.isMenuVisible ? '☑︎' : '☐' } Mostra selettore modelli`,
async () => {
this.isMenuVisible = !this.isMenuVisible;
await GM.setValue( 'isMenuVisible', this.isMenuVisible );
if (this.modelSelector) this.modelSelector.classList.toggle( 'hidden', !this.isMenuVisible );
await this.reloadMenuVisibleToggle();
}
);
}
async reloadMenuVerticalToggle() {
if (this.isMenuVerticalCommandId) {
try { await GM.unregisterMenuCommand(this.isMenuVerticalCommandId); } catch (e) { console.warn("Failed to unregister menu command for orientation:", e); }
}
this.isMenuVerticalCommandId = await GM.registerMenuCommand(
`┖ Stile: ${ this.isMenuVertical ? 'Verticale ↕' : 'Orizzontale ↔' }`,
async () => {
this.isMenuVertical = !this.isMenuVertical;
await GM.setValue( 'isMenuVertical', this.isMenuVertical );
if (!this.modelSelector) return;
const currentRect = this.modelSelector.getBoundingClientRect();
const currentRight = currentRect.right;
const currentBottom = currentRect.bottom;
this.modelSelector.style.visibility = 'hidden';
this.modelSelector.style.left = '0px';
this.modelSelector.style.top = '0px';
this.modelSelector.classList.toggle( 'horizontal', !this.isMenuVertical );
this.modelSelector.offsetHeight;
const newWidth = this.modelSelector.offsetWidth;
const newHeight = this.modelSelector.offsetHeight;
let newLeft = currentRight - newWidth;
let newTop = currentBottom - newHeight;
newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - newWidth));
newTop = Math.max(0, Math.min(newTop, window.innerHeight - newHeight));
this.modelSelector.style.left = `${newLeft}px`;
this.modelSelector.style.top = `${newTop}px`;
this.modelSelector.style.visibility = 'visible';
await GM.setValue( 'relativeMenuPosition', this.getCurrentRelativeMenuPosition() );
await this.reloadMenuVerticalToggle();
},
);
}
injectMessageModelHighlightStyle() {
let style = `
/* Evidenziazione messaggi - stile ChatGPT neutro e uniforme */
div[data-message-model-slug] {
position: relative;
border-radius: 8px;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
margin: 2px 0;
}
/* Indicatore sottile sul bordo sinistro - colore neutro */
div[data-message-model-slug]::before {
content: '';
position: absolute;
left: -4px;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 2px;
background: rgba(156, 163, 175, 0.4);
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Hover effect sottile */
div[data-message-model-slug]:hover {
background: rgba(156, 163, 175, 0.03);
}
div[data-message-model-slug]:hover::before {
background: rgba(156, 163, 175, 0.7);
width: 4px;
left: -5px;
}
/* Badge sottile con nome modello - stile ChatGPT neutro */
div[data-message-model-slug]::after {
content: attr(data-message-model-slug);
position: absolute;
top: 4px;
right: 8px;
background: rgba(156, 163, 175, 0.1);
color: rgba(156, 163, 175, 0.8);
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.6;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
font-family: 'Söhne', 'ui-sans-serif', 'system-ui', sans-serif;
}
div[data-message-model-slug]:hover::after {
opacity: 0.9;
transform: translateY(-1px);
background: rgba(156, 163, 175, 0.15);
color: rgb(156, 163, 175);
}
`;
this.modelHighlightStyleNode = injectStyle( style, !this.isModelHighlightEnabled );
}
async reloadMessageModelHighlightToggle() {
if (this.isModelHighlightEnabledCommandId) {
try { await GM.unregisterMenuCommand(this.isModelHighlightEnabledCommandId); } catch (e) { console.warn("Failed to unregister menu command for highlight:", e); }
}
this.isModelHighlightEnabledCommandId = await GM.registerMenuCommand(
`${ this.isModelHighlightEnabled ? '☑︎' : '☐' } Mostra identificatore modello`,
async () => {
this.isModelHighlightEnabled = !this.isModelHighlightEnabled;
await GM.setValue( 'isModelHighlightEnabled', this.isModelHighlightEnabled );
if (this.modelHighlightStyleNode) this.modelHighlightStyleNode.disabled = !this.isModelHighlightEnabled;
await this.reloadMessageModelHighlightToggle();
},
);
}
createModelSelectorMenu() {
this.modelSelector = document.createElement( 'div' );
this.modelSelector.id = 'model-selector';
// Attributi accessibility
this.modelSelector.setAttribute('role', 'radiogroup');
this.modelSelector.setAttribute('aria-label', 'Selettore modelli ChatGPT');
for ( const [ modelName, modelValue ] of Object.entries( this.availableModels ) ) {
const button = document.createElement( 'button' );
button.textContent = modelName;
button.title = `Passa a ${modelValue}`;
// Attributi accessibility
button.setAttribute('role', 'radio');
button.setAttribute('aria-checked', (modelValue === this.model).toString());
button.setAttribute('tabindex', modelValue === this.model ? '0' : '-1');
// Classi CSS
button.classList.add(`btn-${modelValue}`);
button.addEventListener(
'click',
async event => {
if ( this.shouldCancelClick ) {
event.preventDefault();
event.stopImmediatePropagation();
return;
}
this.model = modelValue;
await GM.setValue( 'model', modelValue );
this.refreshButtons();
// Aggiorna focus per accessibility
this.updateTabIndex();
}
);
// Navigazione keyboard
button.addEventListener('keydown', (event) => {
this.handleKeyNavigation(event, modelValue);
});
this.modelSelector.appendChild( button );
this.buttons[`btn-${ modelValue }`] = button;
}
this.modelSelector.classList.toggle( 'hidden', !this.isMenuVisible );
this.modelSelector.classList.toggle( 'horizontal', !this.isMenuVertical );
return this.modelSelector;
}
// Nuova funzione per gestire la navigazione keyboard
handleKeyNavigation(event, currentModelValue) {
const models = Object.values(this.availableModels);
const currentIndex = models.indexOf(currentModelValue);
let newIndex = currentIndex;
switch(event.key) {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault();
newIndex = (currentIndex + 1) % models.length;
break;
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault();
newIndex = (currentIndex - 1 + models.length) % models.length;
break;
case 'Enter':
case ' ':
event.preventDefault();
event.target.click();
return;
case 'Home':
event.preventDefault();
newIndex = 0;
break;
case 'End':
event.preventDefault();
newIndex = models.length - 1;
break;
default:
return;
}
const newModelValue = models[newIndex];
const newButton = this.buttons[`btn-${newModelValue}`];
if (newButton) {
newButton.focus();
}
}
// Nuova funzione per aggiornare il tabindex
updateTabIndex() {
for (const [modelKey, button] of Object.entries(this.buttons)) {
const modelValue = modelKey.substring(4);
const isSelected = modelValue === this.model;
button.setAttribute('tabindex', isSelected ? '0' : '-1');
button.setAttribute('aria-checked', isSelected.toString());
}
}
injectMenu() {
if (this.modelSelector && !document.body.querySelector('#model-selector')) {
document.body.appendChild( this.modelSelector );
}
}
monitorBodyChanges() {
// Throttled handler per ridurre overhead
const handleMutations = this.throttle((mutationsList) => {
// Check rapido se il selector esiste prima di operazioni costose
if (document.getElementById('model-selector')) return;
// Verifica più specifica: solo se il selector è stato rimosso
if (this.modelSelector && !document.body.contains(this.modelSelector)) {
this.injectMenu();
}
}, 16, 'mutations'); // ~60fps throttling
const observer = new MutationObserver((mutationsList) => {
// Early exit se non ci sono cambiamenti rilevanti
let hasRelevantChanges = false;
for (let i = 0; i < mutationsList.length; i++) {
const mutation = mutationsList[i];
if (mutation.type === 'childList' &&
(mutation.removedNodes.length > 0 || mutation.addedNodes.length > 0)) {
hasRelevantChanges = true;
break;
}
}
if (hasRelevantChanges) {
handleMutations(mutationsList);
}
});
// Observer più specifico per ridurre falsi trigger
observer.observe(document.body, {
childList: true,
subtree: false,
attributes: false,
characterData: false
});
// Cleanup su unload
window.addEventListener('beforeunload', () => {
observer.disconnect();
}, { once: true });
return observer;
}
// Default position: bottom-right corner
getDefaultRelativeMenuPosition() {
return {
offsetRight: 20,
offsetBottom: 20 // Changed from offsetTop to offsetBottom
};
}
relativeToAbsolutePosition( relativeMenuPosition ) {
if (!this.modelSelector || this.modelSelector.offsetWidth === 0) {
// Fallback if dimensions are not ready
return {
left: `${window.innerWidth - 150 - (relativeMenuPosition.offsetRight || 20)}px`,
top: `${window.innerHeight - 100 - (relativeMenuPosition.offsetBottom || 20)}px` // Approx height
};
}
return {
left: `${ window.innerWidth - this.modelSelector.offsetWidth - relativeMenuPosition.offsetRight }px`,
top: `${ window.innerHeight - this.modelSelector.offsetHeight - relativeMenuPosition.offsetBottom }px` // Use offsetBottom
}
}
getCurrentRelativeMenuPosition() {
if (!this.modelSelector) return this.getDefaultRelativeMenuPosition();
const currentLeft = parseInt(this.modelSelector.style.left, 10) || 0;
const currentTop = parseInt(this.modelSelector.style.top, 10) || 0;
return {
offsetRight: window.innerWidth - currentLeft - this.modelSelector.offsetWidth,
offsetBottom: window.innerHeight - currentTop - this.modelSelector.offsetHeight // Use offsetBottom
}
}
async restoreMenuPosition() {
const oldMenuPosition = await GM.getValue( 'menuPosition', null ); // For <= v0.53.1 migration
if ( oldMenuPosition && oldMenuPosition.left && oldMenuPosition.top ) {
const oldLeftPx = parseInt(oldMenuPosition.left, 10);
const oldTopPx = parseInt(oldMenuPosition.top, 10);
if (this.modelSelector && this.modelSelector.offsetWidth > 0) {
await GM.setValue('relativeMenuPosition', {
offsetRight: window.innerWidth - oldLeftPx - this.modelSelector.offsetWidth,
offsetBottom: window.innerHeight - oldTopPx - this.modelSelector.offsetHeight // Convert to offsetBottom
});
} else {
await GM.setValue('relativeMenuPosition', this.getDefaultRelativeMenuPosition());
}
await GM.deleteValue( 'menuPosition' );
}
const relativeMenuPosition = await GM.getValue( 'relativeMenuPosition', this.getDefaultRelativeMenuPosition() );
const absoluteMenuPosition = this.relativeToAbsolutePosition( relativeMenuPosition );
if (this.modelSelector) {
this.modelSelector.style.left = absoluteMenuPosition.left;
this.modelSelector.style.top = absoluteMenuPosition.top;
this.ensureMenuInBounds();
}
}
ensureMenuInBounds() {
if (!this.modelSelector) return;
const rect = this.modelSelector.getBoundingClientRect();
let newLeft = rect.left;
let newTop = rect.top;
if (rect.right > window.innerWidth) {
newLeft = window.innerWidth - rect.width;
}
if (rect.bottom > window.innerHeight) {
newTop = window.innerHeight - rect.height;
}
newLeft = Math.max(0, newLeft);
newTop = Math.max(0, newTop);
if (newLeft !== rect.left || newTop !== rect.top) {
this.modelSelector.style.left = `${newLeft}px`;
this.modelSelector.style.top = `${newTop}px`;
}
}
monitorWindowResize() {
// Cache delle dimensioni per evitare ricalcoli
let lastWindowWidth = window.innerWidth;
let lastWindowHeight = window.innerHeight;
const handleResize = this.throttle(async () => {
const currentWidth = window.innerWidth;
const currentHeight = window.innerHeight;
// Early exit se le dimensioni non sono cambiate significativamente
const widthDiff = Math.abs(currentWidth - lastWindowWidth);
const heightDiff = Math.abs(currentHeight - lastWindowHeight);
if (widthDiff < 10 && heightDiff < 10) return;
lastWindowWidth = currentWidth;
lastWindowHeight = currentHeight;
if (!this.modelSelector) return;
// Cache della posizione relativa per evitare await in loop
const relativeMenuPosition = this.getCache('relativeMenuPosition') ||
await GM.getValue('relativeMenuPosition', this.getDefaultRelativeMenuPosition());
// Batch DOM operations
const menuWidth = this.modelSelector.offsetWidth;
const menuHeight = this.modelSelector.offsetHeight;
let targetLeft = currentWidth - menuWidth - relativeMenuPosition.offsetRight;
let targetTop = currentHeight - menuHeight - relativeMenuPosition.offsetBottom;
// Clamp values
targetLeft = Math.max(0, Math.min(targetLeft, currentWidth - menuWidth));
targetTop = Math.max(0, Math.min(targetTop, currentHeight - menuHeight));
// Single style update per ridurre reflow
this.modelSelector.style.cssText +=
`left: ${targetLeft}px; top: ${targetTop}px;`;
// Cache della nuova posizione
this.setCache('relativeMenuPosition', relativeMenuPosition, 30000);
}, 16, 'resize'); // 60fps throttling
window.addEventListener('resize', handleResize, { passive: true });
// Cleanup
window.addEventListener('beforeunload', () => {
window.removeEventListener('resize', handleResize);
}, { once: true });
}
async registerResetMenuPositionCommand() {
if (this.resetMenuPositionCommandId) {
try { await GM.unregisterMenuCommand(this.resetMenuPositionCommandId); } catch(e) { console.warn("Failed to unregister reset command:", e); }
}
this.resetMenuPositionCommandId = await GM.registerMenuCommand(
'⟲ Reimposta posizione menu',
async () => {
const defaultRelativeMenuPosition = this.getDefaultRelativeMenuPosition();
await this.waitForElementDimensions(this.modelSelector);
const defaultAbsoluteMenuPosition = this.relativeToAbsolutePosition( defaultRelativeMenuPosition );
if (this.modelSelector) {
this.modelSelector.style.left = defaultAbsoluteMenuPosition.left;
this.modelSelector.style.top = defaultAbsoluteMenuPosition.top;
await GM.setValue( 'relativeMenuPosition', defaultRelativeMenuPosition );
this.ensureMenuInBounds();
}
}
);
}
getPoint( event ) {
return event.touches ? event.touches[0] : event;
}
mouseDownHandler( event ) {
if (!this.modelSelector) return;
if (event.target.tagName === 'BUTTON') return;
const point = this.getPoint( event );
this.offsetX = point.clientX - this.modelSelector.offsetLeft;
this.offsetY = point.clientY - this.modelSelector.offsetTop;
this.isDragging = true;
this.shouldCancelClick = false;
this.modelSelector.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
if (event.cancelable && event.type === 'touchstart') event.preventDefault();
}
mouseMoveHandler( event ) {
if ( !this.isDragging || !this.modelSelector ) return;
// Throttling per mousemove (performance critica)
if (!this.lastMoveTime) this.lastMoveTime = 0;
const now = performance.now();
if (now - this.lastMoveTime < 16) return; // ~60fps
this.lastMoveTime = now;
const point = this.getPoint( event );
const oldLeft = this.modelSelector.offsetLeft;
const oldTop = this.modelSelector.offsetTop;
let newLeft = point.clientX - this.offsetX;
let newTop = point.clientY - this.offsetY;
// Batch style updates per ridurre reflow
this.modelSelector.style.cssText +=
`left: ${newLeft}px; top: ${newTop}px;`;
if ( !this.shouldCancelClick && (this.modelSelector.offsetLeft !== oldLeft || this.modelSelector.offsetTop !== oldTop ) ) {
this.shouldCancelClick = true;
}
if ( event.cancelable && event.type === 'touchmove') event.preventDefault();
}
async mouseUpHandler( event ) {
if (!this.isDragging || !this.modelSelector) return;
this.isDragging = false;
this.lastMoveTime = 0; // Reset throttling
// Batch style updates
this.modelSelector.style.cssText += 'cursor: grab;';
document.body.style.userSelect = '';
this.ensureMenuInBounds();
// Debounce position saving per evitare troppe scritture
const savePosition = this.debounce(async () => {
await GM.setValue( 'relativeMenuPosition', this.getCurrentRelativeMenuPosition() );
}, 200, 'savePosition');
savePosition();
}
registerGrabbing() {
if (!this.modelSelector) return;
// Bind methods once per ridurre allocazioni
this.boundMouseDown = this.mouseDownHandler.bind(this);
this.boundMouseMove = this.mouseMoveHandler.bind(this);
this.boundMouseUp = this.mouseUpHandler.bind(this);
// Event options ottimizzate
const passiveOptions = { passive: true };
const activeOptions = { passive: false };
this.modelSelector.addEventListener( 'mousedown', this.boundMouseDown, passiveOptions );
document.addEventListener( 'mousemove', this.boundMouseMove, passiveOptions );
document.addEventListener( 'mouseup', this.boundMouseUp, passiveOptions );
this.modelSelector.addEventListener( 'touchstart', this.boundMouseDown, activeOptions );
document.addEventListener( 'touchmove', this.boundMouseMove, activeOptions );
document.addEventListener( 'touchend', this.boundMouseUp, passiveOptions );
// Cleanup automatico
window.addEventListener('beforeunload', () => {
this.cleanupEventListeners();
}, { once: true });
}
cleanupEventListeners() {
if (this.boundMouseDown) {
this.modelSelector?.removeEventListener('mousedown', this.boundMouseDown);
document.removeEventListener('mousemove', this.boundMouseMove);
document.removeEventListener('mouseup', this.boundMouseUp);
this.modelSelector?.removeEventListener('touchstart', this.boundMouseDown);
document.removeEventListener('touchmove', this.boundMouseMove);
document.removeEventListener('touchend', this.boundMouseUp);
}
}
async waitForElementDimensions(element, timeout = 500) {
return new Promise(resolve => {
if (element && element.offsetWidth > 0 && element.offsetHeight > 0) {
resolve();
return;
}
if (!element) {
console.warn("waitForElementDimensions: Element is null or undefined.");
resolve();
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
if (element.offsetWidth > 0 && element.offsetHeight > 0) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
console.warn("Element dimensions not ready after timeout:", element);
resolve();
}
}, 50);
});
}
async run() {
try {
// Avvia il sistema di cache cleanup
this.startCacheCleanup();
await this.init();
this.hookFetch();
// Lazy loading degli stili solo quando necessario
const injectStyles = () => {
this.injectToggleButtonStyle();
this.injectMessageModelHighlightStyle();
};
// Defer style injection per non bloccare rendering
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectStyles, { once: true });
} else {
// Use requestIdleCallback se disponibile per non bloccare main thread
if (window.requestIdleCallback) {
requestIdleCallback(injectStyles, { timeout: 1000 });
} else {
setTimeout(injectStyles, 0);
}
}
this.createModelSelectorMenu();
this.injectMenu();
// Async operations che non bloccano UI
const asyncInitialization = async () => {
if (this.modelSelector) {
await this.waitForElementDimensions(this.modelSelector);
}
await Promise.all([
this.registerResetMenuPositionCommand(),
this.reloadMenuVisibleToggle(),
this.reloadMenuVerticalToggle(),
this.reloadMessageModelHighlightToggle()
]);
this.refreshButtons();
await this.restoreMenuPosition();
};
// Setup dei monitor in parallelo
const setupMonitors = () => {
this.monitorBodyChanges();
this.monitorWindowResize();
this.registerGrabbing();
};
// Esegui setup in parallelo
if (window.requestIdleCallback) {
requestIdleCallback(() => {
asyncInitialization().catch(console.warn);
setupMonitors();
}, { timeout: 2000 });
} else {
setTimeout(() => {
asyncInitialization().catch(console.warn);
setupMonitors();
}, 100);
}
} catch (error) {
console.error("Error running ModelSwitcher:", error);
// Fallback: tenta un'inizializzazione minimale
try {
await this.init();
this.hookFetch();
this.createModelSelectorMenu();
this.injectMenu();
} catch (fallbackError) {
console.error("Fallback initialization failed:", fallbackError);
}
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const switcher = new ModelSwitcher();
switcher.run();
}, { once: true, passive: true });
} else {
// Use requestIdleCallback per non interferire con il caricamento della pagina
if (window.requestIdleCallback) {
requestIdleCallback(() => {
const switcher = new ModelSwitcher();
switcher.run();
}, { timeout: 1000 });
} else {
// Fallback per browser più vecchi
setTimeout(() => {
const switcher = new ModelSwitcher();
switcher.run();
}, 0);
}
}
})();
Really useful script for switching models in real time on ChatGPT (even as a Free user)
27 Views | 1 day ago |