您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
A highly customizable AI rates tweets 1-10 and removes all the slop, saving your braincells!
当前为
// ==UserScript== // @name TweetFilter AI // @namespace http://tampermonkey.net/ // @version Version 1.3.6.3 // @description A highly customizable AI rates tweets 1-10 and removes all the slop, saving your braincells! // @author Obsxrver(3than) // @match *://twitter.com/* // @match *://x.com/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_getResourceText // @connect openrouter.ai // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; // Embedded Menu.html const MENU = `<style> /* Modern X-Inspired Styles - Enhanced --------------------------------- */ /* Main tweet filter container */ #tweet-filter-container { position: fixed; top: 70px; right: 15px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 10px 12px; border-radius: 12px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5); display: flex; align-items: center; gap: 10px; border: 1px solid rgba(255, 255, 255, 0.1); } /* Close button styles */ .close-button { background: none; border: none; color: #e7e9ea; font-size: 16px; cursor: pointer; padding: 0; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.2s; border-radius: 50%; /* Enhanced touch target and mobile styles */ min-width: 28px; min-height: 28px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; user-select: none; z-index: 30; } .close-button:hover { opacity: 1; background-color: rgba(255, 255, 255, 0.1); } /* Hidden state */ .hidden { display: none !important; } /* Show/hide button */ .toggle-button { position: fixed; right: 15px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 12px; z-index: 9999; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; align-items: center; gap: 6px; transition: all 0.2s ease; } .toggle-button:hover { background-color: rgba(29, 155, 240, 0.2); } #filter-toggle { top: 70px; } #settings-toggle { top: 120px; } #tweet-filter-container label { margin: 0; font-weight: bold; } #tweet-filter-slider { cursor: pointer; width: 120px; vertical-align: middle; accent-color: #1d9bf0; } #tweet-filter-value { min-width: 20px; text-align: center; font-weight: bold; background-color: rgba(255, 255, 255, 0.1); padding: 2px 5px; border-radius: 4px; } /* Settings UI with Tabs */ #settings-container { position: fixed; top: 70px; right: 15px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 0; /* Remove padding to accommodate sticky header */ border-radius: 16px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; box-shadow: 0 2px 18px rgba(0, 0, 0, 0.6); display: flex; flex-direction: column; width: 380px; max-height: 85vh; overflow: hidden; /* Hide overflow to make the sticky header work properly */ border: 1px solid rgba(255, 255, 255, 0.1); line-height: 1.3; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); transform-origin: top right; } #settings-container.hidden { opacity: 0; transform: scale(0.9); pointer-events: none; } /* Header section */ .settings-header { padding: 12px 15px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; background-color: rgba(22, 24, 28, 0.98); z-index: 20; border-radius: 16px 16px 0 0; } .settings-title { font-weight: bold; font-size: 16px; } /* Content area with scrolling */ .settings-content { overflow-y: auto; max-height: calc(85vh - 110px); /* Account for header and tabs */ padding: 0; } /* Scrollbar styling for settings container */ .settings-content::-webkit-scrollbar { width: 6px; } .settings-content::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); border-radius: 3px; } .settings-content::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; } .settings-content::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* Tab Navigation */ .tab-navigation { display: flex; border-bottom: 1px solid rgba(255, 255, 255, 0.1); position: sticky; top: 0; background-color: rgba(22, 24, 28, 0.98); z-index: 10; padding: 10px 15px; gap: 8px; } .tab-button { padding: 6px 10px; background: none; border: none; color: #e7e9ea; font-weight: bold; cursor: pointer; border-radius: 8px; transition: all 0.2s ease; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; flex: 1; text-align: center; } .tab-button:hover { background-color: rgba(255, 255, 255, 0.1); } .tab-button.active { color: #1d9bf0; background-color: rgba(29, 155, 240, 0.1); border-bottom: 2px solid #1d9bf0; } /* Tab Content */ .tab-content { display: none; animation: fadeIn 0.3s ease; padding: 15px; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .tab-content.active { display: block; } /* Enhanced dropdowns */ .select-container { position: relative; margin-bottom: 15px; } .select-container .search-field { position: sticky; top: 0; background-color: rgba(39, 44, 48, 0.95); padding: 8px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); z-index: 1; } .select-container .search-input { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.2); background-color: rgba(39, 44, 48, 0.9); color: #e7e9ea; font-size: 12px; transition: border-color 0.2s; } .select-container .search-input:focus { border-color: #1d9bf0; outline: none; } .custom-select { position: relative; display: inline-block; width: 100%; } .select-selected { background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; padding: 10px 12px; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; cursor: pointer; user-select: none; display: flex; justify-content: space-between; align-items: center; font-size: 13px; transition: border-color 0.2s; } .select-selected:hover { border-color: rgba(255, 255, 255, 0.4); } .select-selected:after { content: ""; width: 8px; height: 8px; border: 2px solid #e7e9ea; border-width: 0 2px 2px 0; display: inline-block; transform: rotate(45deg); margin-left: 10px; transition: transform 0.2s; } .select-selected.select-arrow-active:after { transform: rotate(-135deg); } .select-items { position: absolute; background-color: rgba(39, 44, 48, 0.98); top: 100%; left: 0; right: 0; z-index: 99; max-height: 300px; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; margin-top: 5px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); display: none; } .select-items div { color: #e7e9ea; padding: 10px 12px; cursor: pointer; user-select: none; transition: background-color 0.2s; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } .select-items div:hover { background-color: rgba(29, 155, 240, 0.1); } .select-items div.same-as-selected { background-color: rgba(29, 155, 240, 0.2); } /* Scrollbar for select items */ .select-items::-webkit-scrollbar { width: 6px; } .select-items::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); } .select-items::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; } .select-items::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* Form elements */ #openrouter-api-key, #user-instructions { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.2); margin-bottom: 12px; background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; transition: border-color 0.2s; } #openrouter-api-key:focus, #user-instructions:focus { border-color: #1d9bf0; outline: none; } #user-instructions { height: 120px; resize: vertical; } /* Parameter controls */ .parameter-row { display: flex; align-items: center; margin-bottom: 12px; gap: 8px; padding: 6px; border-radius: 8px; transition: background-color 0.2s; } .parameter-row:hover { background-color: rgba(255, 255, 255, 0.05); } .parameter-label { flex: 1; font-size: 13px; color: #e7e9ea; } .parameter-control { flex: 1.5; display: flex; align-items: center; gap: 8px; } .parameter-value { min-width: 28px; text-align: center; background-color: rgba(255, 255, 255, 0.1); padding: 3px 5px; border-radius: 4px; font-size: 12px; } .parameter-slider { flex: 1; -webkit-appearance: none; height: 4px; border-radius: 4px; background: rgba(255, 255, 255, 0.2); outline: none; cursor: pointer; } .parameter-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #1d9bf0; cursor: pointer; transition: transform 0.1s; } .parameter-slider::-webkit-slider-thumb:hover { transform: scale(1.2); } /* Section styles */ .section-title { font-weight: bold; margin-top: 20px; margin-bottom: 8px; color: #e7e9ea; display: flex; align-items: center; gap: 6px; font-size: 14px; } .section-title:first-child { margin-top: 0; } .section-description { font-size: 12px; margin-bottom: 8px; opacity: 0.8; line-height: 1.4; } /* Advanced options section */ .advanced-options { margin-top: 5px; margin-bottom: 15px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 12px; background-color: rgba(255, 255, 255, 0.03); overflow: hidden; } .advanced-toggle { display: flex; justify-content: space-between; align-items: center; cursor: pointer; margin-bottom: 5px; } .advanced-toggle-title { font-weight: bold; font-size: 13px; color: #e7e9ea; } .advanced-toggle-icon { transition: transform 0.3s; } .advanced-toggle-icon.expanded { transform: rotate(180deg); } .advanced-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-in-out; } .advanced-content.expanded { max-height: 300px; } /* Handle list styling */ .handle-list { margin-top: 10px; max-height: 120px; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 5px; } .handle-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-radius: 4px; transition: background-color 0.2s; } .handle-item:hover { background-color: rgba(255, 255, 255, 0.05); } .handle-item:last-child { border-bottom: none; } .handle-text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 12px; } .remove-handle { background: none; border: none; color: #ff5c5c; cursor: pointer; font-size: 14px; padding: 0 3px; opacity: 0.7; transition: opacity 0.2s; } .remove-handle:hover { opacity: 1; } .add-handle-btn { background-color: #1d9bf0; color: white; border: none; border-radius: 6px; padding: 7px 10px; cursor: pointer; font-weight: bold; font-size: 12px; margin-left: 5px; transition: background-color 0.2s; } .add-handle-btn:hover { background-color: #1a8cd8; } /* Button styling */ .settings-button { background-color: #1d9bf0; color: white; border: none; border-radius: 8px; padding: 10px 14px; cursor: pointer; font-weight: bold; transition: background-color 0.2s; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin-top: 8px; width: 100%; font-size: 13px; } .settings-button:hover { background-color: #1a8cd8; } .settings-button.secondary { background-color: rgba(255, 255, 255, 0.1); } .settings-button.secondary:hover { background-color: rgba(255, 255, 255, 0.15); } .settings-button.danger { background-color: #ff5c5c; } .settings-button.danger:hover { background-color: #e53935; } /* For smaller buttons that sit side by side */ .button-row { display: flex; gap: 8px; margin-top: 10px; } .button-row .settings-button { margin-top: 0; } /* Stats display */ .stats-container { background-color: rgba(255, 255, 255, 0.05); padding: 10px; border-radius: 8px; margin-bottom: 15px; } .stats-row { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .stats-row:last-child { border-bottom: none; } .stats-label { font-size: 12px; opacity: 0.8; } .stats-value { font-weight: bold; } /* Rating indicator shown on tweets */ .score-indicator { position: absolute; top: 10px; right: 10.5%; background-color: rgba(22, 24, 28, 0.9); color: #e7e9ea; padding: 4px 10px; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: bold; z-index: 100; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.1); min-width: 20px; text-align: center; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); transition: transform 0.15s ease; } .score-indicator:hover { transform: scale(1.05); } /* Mobile indicator positioning */ .score-indicator.mobile-indicator { position: absolute !important; bottom: 3% !important; right: 10px !important; top: auto !important; } /* Base description box styles */ .score-description { display: none; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 16px 20px; border-radius: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5; z-index: 99999999; position: absolute; width: 550px !important; max-width: 80vw !important; max-height: 60vh; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.1); word-wrap: break-word; box-sizing: border-box !important; padding-left: 25px !important; padding-right: 25px !important; } .score-description.pinned { border: 2px solid #1d9bf0 !important; } /* Tooltip controls */ .tooltip-controls { display: flex !important; justify-content: flex-end !important; margin-bottom: 15px !important; position: sticky !important; top: 0 !important; background-color: #15202b !important; padding-bottom: 5px !important; z-index: 2 !important; } .tooltip-pin-button, .tooltip-copy-button { background: none !important; border: none !important; color: #8899a6 !important; cursor: pointer !important; font-size: 16px !important; padding: 4px 8px !important; margin-left: 8px !important; border-radius: 4px !important; transition: background-color 0.2s !important; } .tooltip-pin-button:hover, .tooltip-copy-button:hover { background-color: rgba(29, 155, 240, 0.1) !important; color: #1d9bf0 !important; } /* Description text */ .description-text { margin: 0 0 25px 0 !important; font-size: 15px !important; line-height: 1.6 !important; max-width: 100% !important; overflow-wrap: break-word !important; padding: 5px 10px !important; & strong, & em, & br { display: inline-block !important; margin: 0 !important; padding: 0 !important; line-height: inherit !important; } & br { margin-bottom: 0.5em !important; } } .tooltip-bottom-spacer { height: 30px !important; width: 100% !important; margin-bottom: 10px !important; } /* Reasoning dropdown */ .reasoning-dropdown { margin-top: 15px !important; border-top: 1px solid rgba(255, 255, 255, 0.1) !important; padding-top: 10px !important; } .reasoning-toggle { display: flex !important; align-items: center !important; color: #1d9bf0 !important; cursor: pointer !important; font-weight: bold !important; padding: 5px !important; user-select: none !important; } .reasoning-toggle:hover { background-color: rgba(29, 155, 240, 0.1) !important; border-radius: 4px !important; } .reasoning-arrow { display: inline-block !important; margin-right: 5px !important; transition: transform 0.2s ease !important; } .reasoning-content { max-height: 0 !important; overflow: hidden !important; transition: max-height 0.3s ease-out, padding 0.3s ease-out !important; background-color: rgba(0, 0, 0, 0.15) !important; border-radius: 5px !important; margin-top: 5px !important; padding: 0 !important; } .reasoning-dropdown.expanded .reasoning-content { max-height: 350px !important; overflow-y: auto !important; padding: 10px !important; } .reasoning-dropdown.expanded .reasoning-arrow { transform: rotate(90deg) !important; } .reasoning-text { font-size: 14px !important; line-height: 1.4 !important; color: #ccc !important; margin: 0 !important; padding: 5px !important; } /* Scroll to bottom button */ .scroll-to-bottom-button { position: sticky; bottom: 0; width: 100%; background-color: rgba(29, 155, 240, 0.9); color: white; text-align: center; padding: 8px 0; cursor: pointer; font-weight: bold; border-top: 1px solid rgba(255, 255, 255, 0.2); margin-top: 10px; z-index: 100; transition: background-color 0.2s; } .scroll-to-bottom-button:hover { background-color: rgba(29, 155, 240, 1); } /* Mobile specific styles */ @media (max-width: 600px) { .score-indicator { position: absolute !important; bottom: 3% !important; right: 10px !important; top: auto !important; } .score-description { width: 90vw !important; max-width: 90vw !important; max-height: 60vh !important; left: 5vw !important; right: 5vw !important; margin: 0 auto !important; padding: 12px !important; box-sizing: border-box !important; overflow-y: auto !important; -webkit-overflow-scrolling: touch !important; overscroll-behavior: contain !important; transform: translateZ(0) !important; /* Force GPU acceleration */ } .reasoning-dropdown.expanded .reasoning-content { max-height: 200px !important; } .close-button { width: 32px; height: 32px; min-width: 32px; min-height: 32px; font-size: 18px; padding: 8px; margin: -4px; /* Compensate for larger padding while maintaining alignment */ } /* Ensure header close button is properly positioned */ .settings-header .close-button { position: relative; right: 0; } } /* Existing styles */ /* Sort container styles */ .sort-container { margin: 10px 0; display: flex; align-items: center; gap: 10px; } .sort-container label { font-size: 14px; color: var(--text-color); } .sort-container select { padding: 5px 10px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2); background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; font-size: 14px; cursor: pointer; } .sort-container select:hover { border-color: #1d9bf0; } .sort-container select:focus { outline: none; border-color: #1d9bf0; box-shadow: 0 0 0 2px rgba(29, 155, 240, 0.2); } /* Dropdown option styling */ .sort-container select option { background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; } </style> <div id="tweetfilter-root-container"> <button id="filter-toggle" class="toggle-button" style="display: none;">Filter Slider</button> <div id="tweet-filter-container"> <button class="close-button" data-action="close-filter">×</button> <label for="tweet-filter-slider">SlopScore:</label> <input type="range" id="tweet-filter-slider" min="0" max="10" step="1"> <span id="tweet-filter-value">5</span> </div> <button id="settings-toggle" class="toggle-button"> <span style="font-size: 14px;">⚙️</span> Settings </button> <div id="settings-container" class="hidden"> <div class="settings-header"> <div class="settings-title">Twitter De-Sloppifier</div> <button class="close-button" data-action="close-settings">×</button> </div> <div class="settings-content"> <div class="tab-navigation"> <button class="tab-button active" data-tab="general">General</button> <button class="tab-button" data-tab="models">Models</button> <button class="tab-button" data-tab="instructions">Instructions</button> </div> <div id="general-tab" class="tab-content active"> <div class="section-title"><span style="font-size: 14px;">🔑</span> OpenRouter API Key <a href="https://openrouter.ai/" target="_blank">Get one here</a></div> <input id="openrouter-api-key" placeholder="Enter your OpenRouter API key"> <button class="settings-button" data-action="save-api-key">Save API Key</button> <div class="section-title" style="margin-top: 20px;"><span style="font-size: 14px;">🗄️</span> Cache Statistics</div> <div class="stats-container"> <div class="stats-row"> <div class="stats-label">Cached Tweet Ratings</div> <div class="stats-value" id="cached-ratings-count">0</div> </div> <div class="stats-row"> <div class="stats-label">Whitelisted Handles</div> <div class="stats-value" id="whitelisted-handles-count">0</div> </div> </div> <button id="clear-cache" class="settings-button danger" data-action="clear-cache">Clear Rating Cache</button> <div class="section-title" style="margin-top: 20px;"> <span style="font-size: 14px;">💾</span> Backup & Restore </div> <div class="section-description"> Export your settings and cached ratings to a file for backup, or import previously saved settings. </div> <div class="button-row"> <button class="settings-button secondary" data-action="export-settings">Export Settings</button> <button class="settings-button secondary" data-action="import-settings">Import Settings</button> </div> <button class="settings-button danger" style="margin-top: 15px;" data-action="reset-settings">Reset to Defaults</button> <div id="version-info" style="margin-top: 20px; font-size: 11px; opacity: 0.6; text-align: center;">Twitter De-Sloppifier v?.?</div> </div> <div id="models-tab" class="tab-content"> <div class="section-title"> <span style="font-size: 14px;">🧠</span> Tweet Rating Model </div> <div class="section-description"> Hint: If you want to rate tweets with images, you need to select an image model. </div> <div class="sort-container"> <label for="model-sort-order">Sort models by: </label> <select id="model-sort-order" data-setting="modelSortOrder"> <option value="price-low-to-high">Price (Low to High)</option> <option value="price-high-to-low">Price (High to Low)</option> <option value="throughput-high-to-low">Throughput (High to Low)</option> <option value="throughput-low-to-high">Throughput (Low to High)</option> <option value="latency-low-to-high">Latency (Low to High)</option> <option value="latency-high-to-low">Latency (High to Low)</option> </select> </div> <div class="select-container" id="model-select-container"> </div> <div class="advanced-options" id="rating-advanced-options"> <div class="advanced-toggle" data-toggle="rating-advanced-content"> <div class="advanced-toggle-title">Advanced Options</div> <div class="advanced-toggle-icon">▼</div> </div> <div class="advanced-content" id="rating-advanced-content"> <div class="parameter-row" data-param-name="modelTemperature"> <div class="parameter-label" title="How random the model responses should be (0.0-1.0)">Temperature</div> <div class="parameter-control"> <input type="range" class="parameter-slider" min="0" max="2" step="0.1"> <input type="number" class="parameter-value" min="0" max="2" step="0.1" style="width: 60px;"> </div> </div> <div class="parameter-row" data-param-name="modelTopP"> <div class="parameter-label" title="Nucleus sampling parameter (0.0-1.0)">Top-p</div> <div class="parameter-control"> <input type="range" class="parameter-slider" min="0" max="1" step="0.1"> <input type="number" class="parameter-value" min="0" max="1" step="0.1" style="width: 60px;"> </div> </div> <div class="parameter-row" data-param-name="maxTokens"> <div class="parameter-label" title="Maximum number of tokens for the response (0 means no limit)">Max Tokens</div> <div class="parameter-control"> <input type="range" class="parameter-slider" min="0" max="2000" step="100"> <input type="number" class="parameter-value" min="0" max="2000" step="100" style="width: 60px;"> </div> </div> <div class="toggle-row"> <div class="toggle-label" title="Stream API responses as they're generated for live updates">Enable Live Streaming</div> <label class="toggle-switch"> <input type="checkbox" data-setting="enableStreaming"> <span class="toggle-slider"></span> </label> </div> </div> </div> <div class="section-title" style="margin-top: 25px;"><span style="font-size: 14px;">🖼️</span> Image Processing Model</div> <div class="section-description">This model generates <strong>text descriptions</strong> of images, which are then sent to the rating model above. If you've selected an image-capable model (🖼️) as your main rating model above, you can disable this to process images directly.</div> <div class="toggle-row"> <div class="toggle-label">Enable Image Descriptions</div> <label class="toggle-switch"> <input type="checkbox" data-setting="enableImageDescriptions"> <span class="toggle-slider"></span> </label> </div> <div id="image-model-container" style="display: none;"> <div class="section-description">Select a model with vision capabilities to describe images in tweets.</div> <div class="select-container" id="image-model-select-container"> </div> <div class="advanced-options" id="image-advanced-options"> <div class="advanced-toggle" data-toggle="image-advanced-content"> <div class="advanced-toggle-title">Advanced Options</div> <div class="advanced-toggle-icon">▼</div> </div> <div class="advanced-content" id="image-advanced-content"> <div class="parameter-row" data-param-name="imageModelTemperature"> <div class="parameter-label" title="Randomness for image descriptions (0.0-1.0)">Temperature</div> <div class="parameter-control"> <input type="range" class="parameter-slider" min="0" max="2" step="0.1"> <input type="number" class="parameter-value" min="0" max="2" step="0.1" style="width: 60px;"> </div> </div> <div class="parameter-row" data-param-name="imageModelTopP"> <div class="parameter-label" title="Nucleus sampling for image model (0.0-1.0)">Top-p</div> <div class="parameter-control"> <input type="range" class="parameter-slider" min="0" max="1" step="0.1"> <input type="number" class="parameter-value" min="0" max="1" step="0.1" style="width: 60px;"> </div> </div> </div> </div> </div> </div> <div id="instructions-tab" class="tab-content"> <div class="section-title">Custom Tweet Rating Instructions</div> <div class="section-description">Add custom instructions for how the model should score tweets:</div> <textarea id="user-instructions" placeholder="Examples: - Give high scores to tweets about technology - Penalize clickbait-style tweets - Rate educational content higher" data-setting="userDefinedInstructions" value=""></textarea> <button class="settings-button" data-action="save-instructions">Save Instructions</button> <div class="section-title" style="margin-top: 20px;">Auto-Rate Handles as 10/10</div> <div class="section-description">Add Twitter handles to automatically rate as 10/10:</div> <div style="display: flex; align-items: center; gap: 5px;"> <input id="handle-input" type="text" placeholder="Twitter handle (without @)"> <button class="add-handle-btn" data-action="add-handle">Add</button> </div> <div class="handle-list" id="handle-list"> </div> </div> </div> <div id="status-indicator" class=""></div> </div> </div>`; // Embedded style.css const STYLE = `/* Modern X-Inspired Styles - Enhanced --------------------------------- */ /* Main tweet filter container */ #tweet-filter-container { position: fixed; top: 70px; right: 15px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 10px 12px; border-radius: 12px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5); display: flex; align-items: center; gap: 10px; border: 1px solid rgba(255, 255, 255, 0.1); } /* Close button styles */ .close-button { background: none; border: none; color: #e7e9ea; font-size: 16px; cursor: pointer; padding: 0; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.2s; border-radius: 50%; } .close-button:hover { opacity: 1; background-color: rgba(255, 255, 255, 0.1); } /* Hidden state */ .hidden { display: none !important; } /* Show/hide button */ .toggle-button { position: fixed; right: 15px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 8px 12px; border-radius: 8px; cursor: pointer; font-size: 12px; z-index: 9999; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; align-items: center; gap: 6px; transition: all 0.2s ease; } .toggle-button:hover { background-color: rgba(29, 155, 240, 0.2); } #filter-toggle { top: 70px; } #settings-toggle { top: 120px; } #tweet-filter-container label { margin: 0; font-weight: bold; } #tweet-filter-slider { cursor: pointer; width: 120px; vertical-align: middle; accent-color: #1d9bf0; } #tweet-filter-value { min-width: 20px; text-align: center; font-weight: bold; background-color: rgba(255, 255, 255, 0.1); padding: 2px 5px; border-radius: 4px; } /* Settings UI with Tabs */ #settings-container { position: fixed; top: 70px; right: 15px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 0; /* Remove padding to accommodate sticky header */ border-radius: 16px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; box-shadow: 0 2px 18px rgba(0, 0, 0, 0.6); display: flex; flex-direction: column; width: 380px; max-height: 85vh; overflow: hidden; /* Hide overflow to make the sticky header work properly */ border: 1px solid rgba(255, 255, 255, 0.1); line-height: 1.3; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); transform-origin: top right; } #settings-container.hidden { opacity: 0; transform: scale(0.9); pointer-events: none; } /* Header section */ .settings-header { padding: 12px 15px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; background-color: rgba(22, 24, 28, 0.98); z-index: 20; border-radius: 16px 16px 0 0; } .settings-title { font-weight: bold; font-size: 16px; } /* Content area with scrolling */ .settings-content { overflow-y: auto; max-height: calc(85vh - 110px); /* Account for header and tabs */ padding: 0; } /* Scrollbar styling for settings container */ .settings-content::-webkit-scrollbar { width: 6px; } .settings-content::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); border-radius: 3px; } .settings-content::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; } .settings-content::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* Tab Navigation */ .tab-navigation { display: flex; border-bottom: 1px solid rgba(255, 255, 255, 0.1); position: sticky; top: 0; background-color: rgba(22, 24, 28, 0.98); z-index: 10; padding: 10px 15px; gap: 8px; } .tab-button { padding: 6px 10px; background: none; border: none; color: #e7e9ea; font-weight: bold; cursor: pointer; border-radius: 8px; transition: all 0.2s ease; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; flex: 1; text-align: center; } .tab-button:hover { background-color: rgba(255, 255, 255, 0.1); } .tab-button.active { color: #1d9bf0; background-color: rgba(29, 155, 240, 0.1); border-bottom: 2px solid #1d9bf0; } /* Tab Content */ .tab-content { display: none; animation: fadeIn 0.3s ease; padding: 15px; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .tab-content.active { display: block; } /* Enhanced dropdowns */ .select-container { position: relative; margin-bottom: 15px; } .select-container .search-field { position: sticky; top: 0; background-color: rgba(39, 44, 48, 0.95); padding: 8px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); z-index: 1; } .select-container .search-input { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.2); background-color: rgba(39, 44, 48, 0.9); color: #e7e9ea; font-size: 12px; transition: border-color 0.2s; } .select-container .search-input:focus { border-color: #1d9bf0; outline: none; } .custom-select { position: relative; display: inline-block; width: 100%; } .select-selected { background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; padding: 10px 12px; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; cursor: pointer; user-select: none; display: flex; justify-content: space-between; align-items: center; font-size: 13px; transition: border-color 0.2s; } .select-selected:hover { border-color: rgba(255, 255, 255, 0.4); } .select-selected:after { content: ""; width: 8px; height: 8px; border: 2px solid #e7e9ea; border-width: 0 2px 2px 0; display: inline-block; transform: rotate(45deg); margin-left: 10px; transition: transform 0.2s; } .select-selected.select-arrow-active:after { transform: rotate(-135deg); } .select-items { position: absolute; background-color: rgba(39, 44, 48, 0.98); top: 100%; left: 0; right: 0; z-index: 99; max-height: 300px; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; margin-top: 5px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); display: none; } .select-items div { color: #e7e9ea; padding: 10px 12px; cursor: pointer; user-select: none; transition: background-color 0.2s; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } .select-items div:hover { background-color: rgba(29, 155, 240, 0.1); } .select-items div.same-as-selected { background-color: rgba(29, 155, 240, 0.2); } /* Scrollbar for select items */ .select-items::-webkit-scrollbar { width: 6px; } .select-items::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); } .select-items::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; } .select-items::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* Form elements */ #openrouter-api-key, #user-instructions { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.2); margin-bottom: 12px; background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; transition: border-color 0.2s; } #openrouter-api-key:focus, #user-instructions:focus { border-color: #1d9bf0; outline: none; } #user-instructions { height: 120px; resize: vertical; } /* Parameter controls */ .parameter-row { display: flex; align-items: center; margin-bottom: 12px; gap: 8px; padding: 6px; border-radius: 8px; transition: background-color 0.2s; } .parameter-row:hover { background-color: rgba(255, 255, 255, 0.05); } .parameter-label { flex: 1; font-size: 13px; color: #e7e9ea; } .parameter-control { flex: 1.5; display: flex; align-items: center; gap: 8px; } .parameter-value { min-width: 28px; text-align: center; background-color: rgba(255, 255, 255, 0.1); padding: 3px 5px; border-radius: 4px; font-size: 12px; } .parameter-slider { flex: 1; -webkit-appearance: none; height: 4px; border-radius: 4px; background: rgba(255, 255, 255, 0.2); outline: none; cursor: pointer; } .parameter-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #1d9bf0; cursor: pointer; transition: transform 0.1s; } .parameter-slider::-webkit-slider-thumb:hover { transform: scale(1.2); } /* Section styles */ .section-title { font-weight: bold; margin-top: 20px; margin-bottom: 8px; color: #e7e9ea; display: flex; align-items: center; gap: 6px; font-size: 14px; } .section-title:first-child { margin-top: 0; } .section-description { font-size: 12px; margin-bottom: 8px; opacity: 0.8; line-height: 1.4; } /* Advanced options section */ .advanced-options { margin-top: 5px; margin-bottom: 15px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 12px; background-color: rgba(255, 255, 255, 0.03); overflow: hidden; } .advanced-toggle { display: flex; justify-content: space-between; align-items: center; cursor: pointer; margin-bottom: 5px; } .advanced-toggle-title { font-weight: bold; font-size: 13px; color: #e7e9ea; } .advanced-toggle-icon { transition: transform 0.3s; } .advanced-toggle-icon.expanded { transform: rotate(180deg); } .advanced-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-in-out; } .advanced-content.expanded { max-height: 300px; } /* Handle list styling */ .handle-list { margin-top: 10px; max-height: 120px; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 5px; } .handle-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); border-radius: 4px; transition: background-color 0.2s; } .handle-item:hover { background-color: rgba(255, 255, 255, 0.05); } .handle-item:last-child { border-bottom: none; } .handle-text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 12px; } .remove-handle { background: none; border: none; color: #ff5c5c; cursor: pointer; font-size: 14px; padding: 0 3px; opacity: 0.7; transition: opacity 0.2s; } .remove-handle:hover { opacity: 1; } .add-handle-btn { background-color: #1d9bf0; color: white; border: none; border-radius: 6px; padding: 7px 10px; cursor: pointer; font-weight: bold; font-size: 12px; margin-left: 5px; transition: background-color 0.2s; } .add-handle-btn:hover { background-color: #1a8cd8; } /* Button styling */ .settings-button { background-color: #1d9bf0; color: white; border: none; border-radius: 8px; padding: 10px 14px; cursor: pointer; font-weight: bold; transition: background-color 0.2s; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin-top: 8px; width: 100%; font-size: 13px; } .settings-button:hover { background-color: #1a8cd8; } .settings-button.secondary { background-color: rgba(255, 255, 255, 0.1); } .settings-button.secondary:hover { background-color: rgba(255, 255, 255, 0.15); } .settings-button.danger { background-color: #ff5c5c; } .settings-button.danger:hover { background-color: #e53935; } /* For smaller buttons that sit side by side */ .button-row { display: flex; gap: 8px; margin-top: 10px; } .button-row .settings-button { margin-top: 0; } /* Stats display */ .stats-container { background-color: rgba(255, 255, 255, 0.05); padding: 10px; border-radius: 8px; margin-bottom: 15px; } .stats-row { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .stats-row:last-child { border-bottom: none; } .stats-label { font-size: 12px; opacity: 0.8; } .stats-value { font-weight: bold; } /* Rating indicator shown on tweets */ .score-indicator { position: absolute; top: 10px; right: 10.5%; background-color: rgba(22, 24, 28, 0.9); color: #e7e9ea; padding: 4px 10px; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; font-weight: bold; z-index: 100; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.1); min-width: 20px; text-align: center; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); transition: transform 0.15s ease; } .score-indicator:hover { transform: scale(1.05); } /* Refresh animation */ .refreshing { animation: spin 1s infinite linear; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* The description box for ratings */ .score-description { display: none; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 16px 20px; border-radius: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5; z-index: 99999999; position: fixed; width: clamp(300px, 30vw, 500px); max-height: 60vh; overflow-y: auto; border: 1px solid rgba(255, 255, 255, 0.1); word-wrap: break-word; } /* Ensure the tooltip scrolls to the bottom during streaming */ .score-description.streaming-tooltip { scroll-behavior: smooth; border-left: 3px solid #1d9bf0; background-color: rgba(25, 30, 35, 0.98); } /* Add a small badge to indicate streaming status */ .score-description.streaming-tooltip::before { content: 'Live'; position: absolute; top: 10px; right: 10px; background-color: #1d9bf0; color: white; font-size: 11px; padding: 2px 6px; border-radius: 10px; font-weight: bold; } /* Add scrollbars to tooltip for better UX */ .score-description::-webkit-scrollbar { width: 6px; } .score-description::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); border-radius: 3px; } .score-description::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; } .score-description::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* Add animated cursor to the streaming tooltip content */ .score-description.streaming-tooltip p::after { content: '|'; display: inline-block; color: #1d9bf0; animation: blink 0.7s infinite; font-weight: bold; margin-left: 2px; } @keyframes blink { 0%, 100% { opacity: 0; } 50% { opacity: 1; } } .streaming-rating { background-color: rgba(33, 150, 243, 0.9) !important; color: white !important; animation: pulse 1.5s infinite alternate; /* Add pulsing animation for live updates */ position: relative; } /* Add a small dot to indicate streaming in progress */ .streaming-rating::after { content: ''; position: absolute; top: -2px; right: -2px; width: 6px; height: 6px; background-color: #1d9bf0; border-radius: 50%; animation: blink 0.7s infinite; box-shadow: 0 0 4px #1d9bf0; } /* Rating status classes */ .cached-rating { background-color: rgba(76, 175, 80, 0.9) !important; color: white !important; } .rated-rating { background-color: rgba(33, 33, 33, 0.9) !important; color: white !important; } .blacklisted-rating { background-color: rgba(255, 193, 7, 0.9) !important; color: black !important; } .pending-rating { background-color: rgba(255, 152, 0, 0.9) !important; color: white !important; } @keyframes pulse { 0% { opacity: 0.8; } 100% { opacity: 1; } } .error-rating { background-color: rgba(244, 67, 54, 0.9) !important; color: white !important; } /* Status indicator at bottom-right */ #status-indicator { position: fixed; bottom: 20px; right: 20px; background-color: rgba(22, 24, 28, 0.95); color: #e7e9ea; padding: 10px 15px; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 12px; z-index: 9999; display: none; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4); transform: translateY(100px); transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } #status-indicator.active { display: block; transform: translateY(0); } /* Toggle switch styling */ .toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; } .toggle-switch input { opacity: 0; width: 0; height: 0; } .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(255, 255, 255, 0.2); transition: .3s; border-radius: 34px; } .toggle-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 2px; bottom: 2px; background-color: white; transition: .3s; border-radius: 50%; } input:checked + .toggle-slider { background-color: #1d9bf0; } input:checked + .toggle-slider:before { transform: translateX(16px); } .toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; margin-bottom: 12px; background-color: rgba(255, 255, 255, 0.05); border-radius: 8px; transition: background-color 0.2s; } .toggle-row:hover { background-color: rgba(255, 255, 255, 0.08); } .toggle-label { font-size: 13px; color: #e7e9ea; } /* Existing styles */ /* Sort container styles */ .sort-container { margin: 10px 0; display: flex; align-items: center; gap: 10px; } .sort-container label { font-size: 14px; color: var(--text-color); } .sort-container select { padding: 5px 10px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.2); background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; font-size: 14px; cursor: pointer; } .sort-container select:hover { border-color: #1d9bf0; } .sort-container select:focus { outline: none; border-color: #1d9bf0; box-shadow: 0 0 0 2px rgba(29, 155, 240, 0.2); } /* Dropdown option styling */ .sort-container select option { background-color: rgba(39, 44, 48, 0.95); color: #e7e9ea; }`; // Apply CSS GM_addStyle(STYLE); // Set menu HTML GM_setValue('menuHTML', MENU); // ----- twitter-desloppifier.js ----- const VERSION = '1.3.6.2'; (function () { 'use strict'; // Load CSS stylesheet //const css = GM_getResourceText('STYLESHEET'); let menuhtml = GM_getResourceText("MENU_HTML"); GM_setValue('menuHTML', menuhtml); let firstRun = GM_getValue('firstRun', true); //GM_addStyle(css); // ----- Initialization ----- /** * Initializes the observer on the main content area, adds the UI elements, * starts processing visible tweets, and sets up periodic checks. */ function initializeObserver() { const target = document.querySelector('main') || document.querySelector('div[data-testid="primaryColumn"]'); if (target) { observedTargetNode = target; initialiseUI(); if (firstRun) { resetSettings(true); GM_setValue('firstRun', false); } // If no API key is found, prompt the user let apiKey = GM_getValue('openrouter-api-key', ''); if(!apiKey){ alert("No API Key found. Please enter your API Key in Settings > General.") } /* if (!apiKey){ //key is dead apiKey = '*' showStatus(`No API Key Found. Using Promotional Key`); }*/ if (apiKey) { GM_setValue('openrouter-api-key', apiKey); showStatus(`Loaded ${Object.keys(tweetIDRatingCache).length} cached ratings. Starting to rate visible tweets...`); fetchAvailableModels(); } // Process all currently visible tweets observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR).forEach(scheduleTweetProcessing); // Apply filtering based on current threshold applyFilteringToAll(); const observer = new MutationObserver(handleMutations); observer.observe(observedTargetNode, { childList: true, subtree: true }); ensureAllTweetsRated(); window.addEventListener('beforeunload', () => { saveTweetRatings(); observer.disconnect(); const sliderUI = document.getElementById('tweet-filter-container'); if (sliderUI) sliderUI.remove(); const settingsUI = document.getElementById('settings-container'); if (settingsUI) settingsUI.remove(); const statusIndicator = document.getElementById('status-indicator'); if (statusIndicator) statusIndicator.remove(); //Now WHY TF did it call this LMAO. That's why it was broken! //cleanupDescriptionElements(); }); } else { setTimeout(initializeObserver, 1000); } } // Start observing tweets and initializing the UI initializeObserver(); })(); // ----- config.js ----- const processedTweets = new Set(); // Set of tweet IDs already processed in this session /** * Cache for tweet ratings - each entry should have: * Required fields: * - score: Number - The numerical rating score (must not be undefined/null) * * Optional fields: * - description: String - Text description of the rating * - tweetContent: String - Full context of the tweet * - streaming: Boolean - Whether rating is still being streamed (should be false for completed entries) * - reasoning: String - Reasoning behind the rating * - fromStorage: Boolean - Whether entry was loaded from persistent storage * - threadContext: Object - Contains thread relationship data: * - replyTo: String - Username being replied to * - replyToId: String - Tweet ID being replied to * - isRoot: Boolean - Whether this is a root tweet * - threadMediaUrls: Array - Media URLs from previous tweets in thread */ const tweetIDRatingCache = {}; // ID-based cache for persistent storage /** * Removes invalid entries from tweetIDRatingCache, including: * - Entries with undefined/null scores * - Streaming entries with undefined scores * @param {boolean} saveAfterCleanup - Whether to save the cache after cleanup * @returns {Object} - Statistics about the cleanup operation */ function cleanupInvalidCacheEntries(saveAfterCleanup = true) { const beforeCount = Object.keys(tweetIDRatingCache).length; let deletedCount = 0; let streamingDeletedCount = 0; let undefinedScoreCount = 0; // Iterate through all entries for (const tweetId in tweetIDRatingCache) { const entry = tweetIDRatingCache[tweetId]; // Check for invalid entries if (entry.score === undefined || entry.score === null) { // Count streaming entries separately if (entry.streaming === true) { streamingDeletedCount++; } else { undefinedScoreCount++; } // Delete the invalid entry delete tweetIDRatingCache[tweetId]; deletedCount++; } } // Save the cleaned cache if requested if (saveAfterCleanup && deletedCount > 0) { saveTweetRatings(); } // Return cleanup statistics return { beforeCount, afterCount: Object.keys(tweetIDRatingCache).length, deletedCount, streamingDeletedCount, undefinedScoreCount }; } const PROCESSING_DELAY_MS = 100; // Delay before processing a tweet (ms) const API_CALL_DELAY_MS = 20; // Minimum delay between API calls (ms) let USER_DEFINED_INSTRUCTIONS = GM_getValue('userDefinedInstructions', `- Give high scores to insightful and impactful tweets - Give low scores to clickbait, fearmongering, and ragebait - Give high scores to high-effort content and artistic content`); let currentFilterThreshold = GM_getValue('filterThreshold', 1); // Filter threshold for tweet visibility let observedTargetNode = null; let lastAPICallTime = 0; let pendingRequests = 0; const MAX_RETRIES = 3; let availableModels = []; // List of models fetched from API let selectedModel = GM_getValue('selectedModel', 'openai/gpt-4.1-nano'); let selectedImageModel = GM_getValue('selectedImageModel', 'openai/gpt-4.1-nano'); let blacklistedHandles = GM_getValue('blacklistedHandles', '').split('\n').filter(h => h.trim() !== ''); let storedRatings = GM_getValue('tweetRatings', '{}'); let threadHist = ""; // Settings variables let enableImageDescriptions = GM_getValue('enableImageDescriptions', false); let enableStreaming = GM_getValue('enableStreaming', true); // Enable streaming by default for better UX // Model parameters const SYSTEM_PROMPT=`You are a tweet filtering AI. Your task is to rate tweets on a scale of 0 to 10 based on user-defined instructions. You will be given a Tweet, structured like this: _______TWEET SCHEMA_______ _______BEGIN TWEET_______ [TWEET {TweetID}] {the text of the tweet being replied to} [MEDIA_DESCRIPTION]: [IMAGE 1]: {description}, [IMAGE 2]: {description}, etc. [REPLY] (if the author is replying to another tweet) [TWEET {TweetID}]: (the tweet which you are to review) @{the author of the tweet} {the text of the tweet} [MEDIA_DESCRIPTION]: [IMAGE 1]: {description}, [IMAGE 2]: {description}, etc. [QUOTED_TWEET]: (if the author is quoting another tweet) {the text of the quoted tweet} [QUOTED_TWEET_MEDIA_DESCRIPTION]: [IMAGE 1]: {description}, [IMAGE 2]: {description}, etc. _______END TWEET_______ _______END TWEET SCHEMA_______ You are to review and provide a rating for the tweet with the specified tweet ID. Ensure that you consider the user-defined instructions in your analysis and scoring. Follow the user-defined instructions exactly, and do not deviate from them. Then, on a new line, provide a score between 0 and 10. Output your final rating in the exact format: (Response to user defined instructions) SCORE_X (where X is a number from 0 (lowest quality) to 10 (highest quality).) for example: SCORE_0, SCORE_1, SCORE_2, SCORE_3, etc. If one of the above is not present, the program will not be able to parse the response and will return an error. ` let modelTemperature = GM_getValue('modelTemperature', 0.5); let modelTopP = GM_getValue('modelTopP', 0.9); let imageModelTemperature = GM_getValue('imageModelTemperature', 0.5); let imageModelTopP = GM_getValue('imageModelTopP', 0.9); let maxTokens = GM_getValue('maxTokens', 0); // Maximum number of tokens for API requests, 0 means no limit let imageModelMaxTokens = GM_getValue('imageModelMaxTokens', 0); // Maximum number of tokens for image model API requests, 0 means no limit //let menuHTML= ""; // ----- DOM Selectors (for tweet elements) ----- const TWEET_ARTICLE_SELECTOR = 'article[data-testid="tweet"]'; const QUOTE_CONTAINER_SELECTOR = 'div[role="link"][tabindex="0"]'; const USER_NAME_SELECTOR = 'div[data-testid="User-Name"] span > span'; const USER_HANDLE_SELECTOR = 'div[data-testid="User-Name"] a[role="link"]'; const TWEET_TEXT_SELECTOR = 'div[data-testid="tweetText"]'; const MEDIA_IMG_SELECTOR = 'div[data-testid="tweetPhoto"] img, img[src*="pbs.twimg.com/media"]'; const MEDIA_VIDEO_SELECTOR = 'video[poster*="pbs.twimg.com"], video'; const PERMALINK_SELECTOR = 'a[href*="/status/"] time'; // ----- Dom Elements ----- /** * Helper function to check if a model supports images based on its architecture * @param {string} modelId - The model ID to check * @returns {boolean} - Whether the model supports image input */ function modelSupportsImages(modelId) { if (!availableModels || availableModels.length === 0) { return false; // If we don't have model info, assume it doesn't support images } const model = availableModels.find(m => m.slug === modelId); if (!model) { return false; // Model not found in available models list } // Check if model supports images based on its architecture return model.input_modalities && model.input_modalities.includes('image'); } function isReasoningModel(modelId){ if (!availableModels || availableModels.length === 0) { return false; // If we don't have model info, assume it doesn't support images } const model = availableModels.find(m => m.slug === modelId); if (!model) { return false; // Model not found in available models list } // Check if model supports images based on its architecture return model.supported_parameters && model.supported_parameters.includes('include_reasoning'); } try { // Load ratings from storage const parsedRatings = JSON.parse(storedRatings); // Mark all ratings from storage as "fromStorage: true" so they'll be // properly recognized as cached when loaded Object.entries(parsedRatings).forEach(([tweetId, ratingData]) => { tweetIDRatingCache[tweetId] = { ...ratingData, fromStorage: true // Mark as loaded from storage }; }); } catch (e) { } // ----- api.js ----- /** * @typedef {Object} CompletionResponse * @property {string} id - Response ID from OpenRouter * @property {string} model - Model used for completion * @property {Array<{ * message: { * role: string, * content: string * }, * finish_reason: string, * index: number * }>} choices - Array of completion choices * @property {Object} usage - Token usage statistics * @property {number} usage.prompt_tokens - Number of tokens in prompt * @property {number} usage.completion_tokens - Number of tokens in completion * @property {number} usage.total_tokens - Total tokens used */ /** * @typedef {Object} CompletionRequest * @property {string} model - Model ID to use * @property {Array<{role: string, content: Array<{type: string, text?: string, image_url?: {url: string}}>}>} messages - Messages for completion * @property {number} temperature - Temperature for sampling * @property {number} top_p - Top P for sampling * @property {number} max_tokens - Maximum tokens to generate * @property {Object} provider - Provider settings * @property {string} provider.sort - Sort order for models * @property {boolean} provider.allow_fallbacks - Whether to allow fallback models */ /** * @typedef {Object} CompletionResult * @property {boolean} error - Whether an error occurred * @property {string} message - Error or success message * @property {CompletionResponse|null} data - The completion response data if successful */ /** * Gets a completion from OpenRouter API * * @param {CompletionRequest} request - The completion request * @param {string} apiKey - OpenRouter API key * @param {number} [timeout=30000] - Request timeout in milliseconds * @returns {Promise<CompletionResult>} The completion result */ async function getCompletion(request, apiKey, timeout = 30000) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "POST", url: "https://openrouter.ai/api/v1/chat/completions", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://greasyfork.org/en/scripts/532459-tweetfilter-ai", "X-Title": "TweetFilter-AI" }, data: JSON.stringify(request), timeout: timeout, onload: function (response) { if (response.status >= 200 && response.status < 300) { try { const data = JSON.parse(response.responseText); if (data.content==="") { resolve({ error: true, message: `No content returned${data.choices[0].native_finish_reason=="SAFETY"?" (SAFETY FILTER)":""}`, data: data }); } resolve({ error: false, message: "Request successful", data: data }); } catch (error) { resolve({ error: true, message: `Failed to parse response: ${error.message}`, data: null }); } } else { resolve({ error: true, message: `Request failed with status ${response.status}: ${response.responseText}`, data: null }); } }, onerror: function (error) { resolve({ error: true, message: `Request error: ${error.toString()}`, data: null }); }, ontimeout: function () { resolve({ error: true, message: `Request timed out after ${timeout}ms`, data: null }); } }); }); } /** * Gets a streaming completion from OpenRouter API * * @param {CompletionRequest} request - The completion request * @param {string} apiKey - OpenRouter API key * @param {Function} onChunk - Callback for each chunk of streamed response * @param {Function} onComplete - Callback when streaming is complete * @param {Function} onError - Callback when an error occurs * @param {number} [timeout=30000] - Request timeout in milliseconds * @param {string} [tweetId=null] - Optional tweet ID to associate with this request * @returns {Object} The request object with an abort method */ function getCompletionStreaming(request, apiKey, onChunk, onComplete, onError, timeout = 30000, tweetId = null) { // Add stream parameter to request const streamingRequest = { ...request, stream: true }; let fullResponse = ""; let content = ""; let reasoning = ""; // Add a variable to track reasoning content let responseObj = null; let streamComplete = false; const reqObj = GM_xmlhttpRequest({ method: "POST", url: "https://openrouter.ai/api/v1/chat/completions", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://greasyfork.org/en/scripts/532459-tweetfilter-ai", "X-Title": "TweetFilter-AI" }, data: JSON.stringify(streamingRequest), timeout: timeout, responseType: "stream", onloadstart: function(response) { // Get the ReadableStream from the response const reader = response.response.getReader(); // Setup timeout to prevent hanging indefinitely const resetStreamTimeout = () => { if (streamTimeout) clearTimeout(streamTimeout); streamTimeout = setTimeout(() => { if (!streamComplete) { streamComplete = true; // Call onComplete with whatever we have so far onComplete({ content: content, reasoning: reasoning, // Include reasoning in onComplete fullResponse: fullResponse, data: responseObj, timedOut: true }); } }, 10000); // 10 second timeout without activity }; let streamTimeout = null; // Process the stream const processStream = async () => { try { resetStreamTimeout() let isDone = false; let emptyChunksCount = 0; while (!isDone && !streamComplete) { const { done, value } = await reader.read(); if (done) { isDone = true; break; } // Convert the chunk to text const chunk = new TextDecoder().decode(value); clearTimeout(streamTimeout); // Reset timeout on activity resetStreamTimeout(); // Check for empty chunks - may indicate end of stream if (chunk.trim() === '') { emptyChunksCount++; // After receiving 3 consecutive empty chunks, consider the stream done if (emptyChunksCount >= 3) { isDone = true; break; } continue; } emptyChunksCount = 0; // Reset the counter if we got content fullResponse += chunk; // Split by lines - server-sent events format const lines = chunk.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { const data = line.substring(6); // Check for the end of the stream if (data === "[DONE]") { isDone = true; break; } try { const parsed = JSON.parse(data); responseObj = parsed; // Extract the content and reasoning if (parsed.choices && parsed.choices[0]) { // Check for delta content if (parsed.choices[0].delta && parsed.choices[0].delta.content !== undefined) { const delta = parsed.choices[0].delta.content || ""; content += delta; } // Check for reasoning in delta if (parsed.choices[0].delta && parsed.choices[0].delta.reasoning !== undefined) { const reasoningDelta = parsed.choices[0].delta.reasoning || ""; reasoning += reasoningDelta; } // Call the chunk callback onChunk({ chunk: parsed.choices[0].delta?.content || "", reasoningChunk: parsed.choices[0].delta?.reasoning || "", content: content, reasoning: reasoning, data: parsed }); } } catch (e) { } } } } // When done, call the complete callback if not already completed if (!streamComplete) { streamComplete = true; if (streamTimeout) clearTimeout(streamTimeout); // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onComplete({ content: content, reasoning: reasoning, // Include reasoning in onComplete fullResponse: fullResponse, data: responseObj }); } } catch (error) { // Make sure we clean up and call onError if (streamTimeout) clearTimeout(streamTimeout); if (!streamComplete) { streamComplete = true; // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onError({ error: true, message: `Stream processing error: ${error.toString()}`, data: null }); } } }; processStream().catch(error => { if (streamTimeout) clearTimeout(streamTimeout); if (!streamComplete) { streamComplete = true; // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onError({ error: true, message: `Unhandled stream error: ${error.toString()}`, data: null }); } }); }, onerror: function(error) { // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onError({ error: true, message: `Request error: ${error.toString()}`, data: null }); }, ontimeout: function() { // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onError({ error: true, message: `Request timed out after ${timeout}ms`, data: null }); } }); // Create an object with an abort method that can be called to cancel the request const streamingRequestObj = { abort: function() { streamComplete = true; // Set flag to prevent further processing try { reqObj.abort(); // Attempt to abort the XHR request } catch (e) { } // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } } }; // Track this request if we have a tweet ID if (tweetId && window.activeStreamingRequests) { window.activeStreamingRequests[tweetId] = streamingRequestObj; } return streamingRequestObj; } /** * Formats description text for the tooltip. * Copy of the function from ui.js to ensure it's available for streaming. */ const safetySettings = [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "BLOCK_NONE", }, ]; /** * Rates a tweet using the OpenRouter API with automatic retry functionality. * * @param {string} tweetText - The text content of the tweet * @param {string} tweetId - The unique tweet ID * @param {string} apiKey - The API key for authentication * @param {string[]} mediaUrls - Array of media URLs associated with the tweet * @param {number} [maxRetries=3] - Maximum number of retry attempts * @returns {Promise<{score: number, content: string, error: boolean, cached?: boolean, data?: any}>} The rating result */ async function rateTweetWithOpenRouter(tweetText, tweetId, apiKey, mediaUrls, maxRetries = 3) { // Create the request body const request = { model: selectedModel, messages: [{ role: "system", content: [{ type: "text", text: ` ${SYSTEM_PROMPT}` },] }, { role: "user", content: [{ type: "text", text: `provide your reasoning, and a rating according to the the following instructions for the tweet with tweet ID ${tweetId}. ${USER_DEFINED_INSTRUCTIONS} _______BEGIN TWEET_______ ${tweetText} _______END TWEET_______ Make sure your response ends with SCORE_0, SCORE_1, SCORE_2, SCORE_3, SCORE_4, SCORE_5, SCORE_6, SCORE_7, SCORE_8, SCORE_9, or SCORE_10.` }] }] }; if (selectedModel.includes('gemini')) { request.config = { safetySettings: safetySettings, }; } // Add image URLs if present and supported if (mediaUrls?.length > 0 && modelSupportsImages(selectedModel)) { for (const url of mediaUrls) { request.messages[1].content.push({ type: "image_url", image_url: { url } }); } } // Add model parameters request.temperature = modelTemperature; request.top_p = modelTopP; request.max_tokens = maxTokens; // Add provider settings const sortOrder = GM_getValue('modelSortOrder', 'throughput-high-to-low'); request.provider = { sort: sortOrder.split('-')[0], allow_fallbacks: true, }; // Check if streaming is enabled const useStreaming = GM_getValue('enableStreaming', false); // Implement retry logic let attempt = 0; while (attempt < maxRetries) { attempt++; // Rate limiting const now = Date.now(); const timeElapsed = now - lastAPICallTime; if (timeElapsed < API_CALL_DELAY_MS) { await new Promise(resolve => setTimeout(resolve, API_CALL_DELAY_MS - timeElapsed)); } lastAPICallTime = now; // Update status pendingRequests++; showStatus(`Rating tweet... (${pendingRequests} pending)`); try { let result; // Call appropriate rating function based on streaming setting if (useStreaming) { result = await rateTweetStreaming(request, apiKey, tweetId, tweetText); } else { result = await rateTweet(request, apiKey); } pendingRequests--; showStatus(`Rating tweet... (${pendingRequests} pending)`); // Parse the result for score if (!result.error && result.content) { const scoreMatch = result.content.match(/SCORE_(\d+)/); if (scoreMatch) { const score = parseInt(scoreMatch[1], 10); return { score, content: result.content, reasoning: result.reasoning, error: false, cached: false, data: result.data }; } } // If we get here, we couldn't find a score in the response if (attempt < maxRetries) { const backoffDelay = Math.pow(attempt, 2) * 1000; await new Promise(resolve => setTimeout(resolve, backoffDelay)); } } catch (error) { pendingRequests--; showStatus(`Rating tweet... (${pendingRequests} pending)`); if (attempt < maxRetries) { const backoffDelay = Math.pow(attempt, 2) * 1000; await new Promise(resolve => setTimeout(resolve, backoffDelay)); } } } // If we get here, all retries failed return { score: 5, content: "Failed to get valid rating after multiple attempts", error: true, data: null }; } /** * Performs a non-streaming tweet rating request * * @param {Object} request - The formatted request body * @param {string} apiKey - API key for authentication * @returns {Promise<{content: string, reasoning: string, error: boolean, data: any}>} The rating result */ async function rateTweet(request, apiKey) { const result = await getCompletion(request, apiKey); if (!result.error && result.data?.choices?.[0]?.message) { const content = result.data.choices[0].message.content || ""; const reasoning = result.data.choices[0].message.reasoning || ""; return { content, reasoning, error: false, data: result.data }; } else { return { content: result.message || "Error getting response", reasoning: "", error: true, data: result.data }; } } /** * Performs a streaming tweet rating request with real-time UI updates * * @param {Object} request - The formatted request body * @param {string} apiKey - API key for authentication * @param {string} tweetId - The tweet ID * @param {string} tweetText - The text content of the tweet * @returns {Promise<{content: string, error: boolean, data: any}>} The rating result */ async function rateTweetStreaming(request, apiKey, tweetId, tweetText) { return new Promise((resolve, reject) => { // Find the tweet article element for this tweet ID const tweetArticle = Array.from(document.querySelectorAll('article[data-testid="tweet"]')) .find(article => getTweetID(article) === tweetId); let aggregatedContent = ""; let aggregatedReasoning = ""; // Track reasoning traces let finalData = null; // Initialize active streaming requests object if it doesn't exist if (!window.activeStreamingRequests) { window.activeStreamingRequests = {}; } // Cancel any existing request for this tweet if (window.activeStreamingRequests[tweetId]) { window.activeStreamingRequests[tweetId].abort(); delete window.activeStreamingRequests[tweetId]; } tweetIDRatingCache[tweetId] = { tweetContent: tweetText, score: null, description: "", reasoning: "", // Store reasoning streaming: true, // Mark as complete timestamp: Date.now() }; saveTweetRatings(); getCompletionStreaming( request, apiKey, // onChunk callback - update the tweet's rating indicator in real-time (chunkData) => { // Use the content and reasoning directly from chunkData instead of aggregating manually aggregatedContent = chunkData.content || "Rating in progress..."; aggregatedReasoning = chunkData.reasoning || ""; if (tweetArticle) { // Look for a score in the accumulated content so far const scoreMatch = aggregatedContent.match(/SCORE_(\d+)/); let currentScore = scoreMatch ? parseInt(scoreMatch[1], 10) : null; // Store references and current state const indicator = tweetArticle.querySelector('.score-indicator'); const tooltip = indicator?.scoreTooltip; // Update the indicator with current partial content tweetArticle.dataset.streamingContent = aggregatedContent; tweetArticle.dataset.ratingStatus = 'streaming'; tweetArticle.dataset.ratingDescription = aggregatedContent; if (aggregatedReasoning) { tweetArticle.dataset.ratingReasoning = aggregatedReasoning; } // Don't cache streaming results - removed partial caching code // Update the tooltip content with both description and reasoning if (tooltip) { // Use the helper function from ui.js to update tooltip content updateTooltipContent(tooltip, aggregatedContent, aggregatedReasoning); tooltip.classList.add('streaming-tooltip'); } if (currentScore !== null && aggregatedReasoning !== "" && aggregatedContent !== "") { // Update the score indicator but preserve tooltip state if (indicator) { // Store the current score tweetArticle.dataset.sloppinessScore = currentScore.toString(); // Update just the score number and class indicator.textContent = currentScore; indicator.className = 'score-indicator streaming-rating'; // Get the tooltip and update only the content const tooltip = indicator.scoreTooltip; if (tooltip) { // Update tooltip content directly without recreating it const descriptionElement = tooltip.querySelector('.description-text'); const reasoningElement = tooltip.querySelector('.reasoning-text'); // Format the text const formatted = formatTooltipDescription(aggregatedContent, aggregatedReasoning); if (descriptionElement) { descriptionElement.innerHTML = formatted.description; } if (reasoningElement) { reasoningElement.innerHTML = formatted.reasoning; } // Preserve expanded state - only show/hide dropdown if reasoning exists const dropdown = tooltip.querySelector('.reasoning-dropdown'); if (dropdown && !formatted.reasoning) { dropdown.style.display = 'none'; } else if (dropdown && formatted.reasoning && dropdown.style.display === 'none') { dropdown.style.display = 'block'; } } } } else if (indicator && (aggregatedReasoning !== "" || aggregatedContent !== "")) { // Handle case where score isn't available yet but reasoning is indicator.className = 'score-indicator streaming-rating'; indicator.textContent = '🔄'; // Update tooltip content directly const tooltip = indicator.scoreTooltip; if (tooltip) { const descriptionElement = tooltip.querySelector('.description-text'); const reasoningElement = tooltip.querySelector('.reasoning-text'); // Format the text - ensure we have at least a placeholder for content const contentToShow = aggregatedContent || "Rating in progress..."; const formatted = formatTooltipDescription(contentToShow, aggregatedReasoning); if (descriptionElement) { descriptionElement.innerHTML = formatted.description; } if (reasoningElement) { reasoningElement.innerHTML = formatted.reasoning; } // Only show/hide dropdown if reasoning exists const dropdown = tooltip.querySelector('.reasoning-dropdown'); if (dropdown && !formatted.reasoning) { dropdown.style.display = 'none'; } else if (dropdown && formatted.reasoning && dropdown.style.display === 'none') { dropdown.style.display = 'block'; } } } } }, // onComplete callback - finalize the rating (finalResult) => { finalData = finalResult.data; // When streaming completes, update the cache with the final result if (tweetArticle) { // Check for a score in the final content const scoreMatch = aggregatedContent.match(/SCORE_(\d+)/); // Also check if we already found a score during streaming const existingScore = tweetIDRatingCache[tweetId]?.score; if (scoreMatch || existingScore) { // if the AI writes multiple scores, use the last one const score = scoreMatch ? parseInt(scoreMatch[scoreMatch.length - 1], 10) : existingScore; // Update cache with final result (non-streaming) tweetIDRatingCache[tweetId] = { tweetContent: tweetText, score: score, description: aggregatedContent, reasoning: finalResult.reasoning || aggregatedReasoning, // Store reasoning streaming: false, // Mark as complete timestamp: Date.now() }; saveTweetRatings(); // Finalize UI update tweetArticle.dataset.ratingStatus = 'rated'; tweetArticle.dataset.ratingDescription = aggregatedContent; tweetArticle.dataset.ratingReasoning = finalResult.reasoning || aggregatedReasoning; tweetArticle.dataset.sloppinessScore = score.toString(); // Remove streaming class from tooltip const indicator = tweetArticle.querySelector('.score-indicator'); if (indicator && indicator.scoreTooltip) { // Update the final tooltip content updateTooltipContent(indicator.scoreTooltip, aggregatedContent, finalResult.reasoning || aggregatedReasoning); indicator.scoreTooltip.classList.remove('streaming-tooltip'); // Set final indicator state - ensure we're not recreating the tooltip indicator.className = 'score-indicator rated-rating'; indicator.textContent = score; } else { // If no indicator exists yet, create one with setScoreIndicator setScoreIndicator(tweetArticle, score, 'rated', aggregatedContent, finalResult.reasoning || aggregatedReasoning); } } else { // If no score was found anywhere, log a warning and set a default score // Set a default score of 5 const defaultScore = 5; // Update cache with default score tweetIDRatingCache[tweetId] = { tweetContent: tweetText, score: defaultScore, description: aggregatedContent + " [No explicit score detected, using default score of 5]", reasoning: finalResult.reasoning || aggregatedReasoning, streaming: false, timestamp: Date.now() }; saveTweetRatings(); // Update UI with default score tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = aggregatedContent; tweetArticle.dataset.ratingReasoning = finalResult.reasoning || aggregatedReasoning; tweetArticle.dataset.sloppinessScore = defaultScore.toString(); // Set indicator with default score const indicator = tweetArticle.querySelector('.score-indicator'); if (indicator) { indicator.className = 'score-indicator rated-rating'; indicator.textContent = defaultScore; if (indicator.scoreTooltip) { updateTooltipContent(indicator.scoreTooltip, aggregatedContent, finalResult.reasoning || aggregatedReasoning); indicator.scoreTooltip.classList.remove('streaming-tooltip'); } } else { setScoreIndicator(tweetArticle, defaultScore, 'rated', aggregatedContent, finalResult.reasoning || aggregatedReasoning); } } } else { } resolve({ content: aggregatedContent, reasoning: finalResult.reasoning || aggregatedReasoning, error: false, data: finalData }); }, // onError callback (errorData) => { // Update UI on error if (tweetArticle) { tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = errorData.message; tweetArticle.dataset.sloppinessScore = '5'; // Remove streaming class from tooltip const indicator = tweetArticle.querySelector('.score-indicator'); if (indicator && indicator.scoreTooltip) { indicator.scoreTooltip.classList.remove('streaming-tooltip'); } setScoreIndicator(tweetArticle, 5, 'error', errorData.message); } reject(new Error(errorData.message)); }, 30000, // timeout tweetId // Pass the tweet ID to associate with this request ); }); } /** * Gets descriptions for images using the OpenRouter API * * @param {string[]} urls - Array of image URLs to get descriptions for * @param {string} apiKey - The API key for authentication * @param {string} tweetId - The unique tweet ID * @param {string} userHandle - The Twitter user handle * @returns {Promise<string>} Combined image descriptions */ async function getImageDescription(urls, apiKey, tweetId, userHandle) { if (!urls?.length || !enableImageDescriptions) { return !enableImageDescriptions ? '[Image descriptions disabled]' : ''; } let descriptions = []; for (const url of urls) { const request = { model: selectedImageModel, messages: [{ role: "user", content: [ { type: "text", text: "Describe what you see in this image in a concise way, focusing on the main elements and any text visible. Keep the description under 100 words." }, { type: "image_url", image_url: { url } } ] }], temperature: imageModelTemperature, top_p: imageModelTopP, max_tokens: maxTokens, provider: { sort: GM_getValue('modelSortOrder', 'throughput-high-to-low').split('-')[0], allow_fallbacks: true } }; if (selectedImageModel.includes('gemini')) { request.config = { safetySettings: safetySettings, } } const result = await getCompletion(request, apiKey); if (!result.error && result.data?.choices?.[0]?.message?.content) { descriptions.push(result.data.choices[0].message.content); } else { descriptions.push('[Error getting image description]'); } } return descriptions.map((desc, i) => `[IMAGE ${i + 1}]: ${desc}`).join('\n'); } /** * Fetches the list of available models from the OpenRouter API. * Uses the stored API key, and updates the model selector upon success. */ function fetchAvailableModels() { const apiKey = GM_getValue('openrouter-api-key', ''); if (!apiKey) { showStatus('Please enter your OpenRouter API key'); return; } showStatus('Fetching available models...'); const sortOrder = GM_getValue('modelSortOrder', 'throughput-high-to-low'); GM_xmlhttpRequest({ method: "GET", url: `https://openrouter.ai/api/frontend/models/find?order=${sortOrder}`, headers: { "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://greasyfork.org/en/scripts/532182-twitter-x-ai-tweet-filter", // Use a more generic referer if preferred "X-Title": "Tweet Rating Tool" }, onload: function (response) { try { const data = JSON.parse(response.responseText); if (data.data && data.data.models) { availableModels = data.data.models || []; refreshModelsUI(); showStatus('Models updated!'); } } catch (error) { showStatus('Error parsing models list'); } }, onerror: function (error) { showStatus('Error fetching models!'); } }); } // ----- domScraper.js ----- /** * Extracts and returns trimmed text content from the given element(s). * @param {Node|NodeList} elements - A DOM element or a NodeList. * @returns {string} The trimmed text content. */ function getElementText(elements) { if (!elements) return ''; const elementList = elements instanceof NodeList ? Array.from(elements) : [elements]; for (const element of elementList) { const text = element?.textContent?.trim(); if (text) return text; } return ''; } /** * Extracts the tweet ID from a tweet article element. * @param {Element} tweetArticle - The tweet article element. * @returns {string} The tweet ID. */ function getTweetID(tweetArticle) { const timeEl = tweetArticle.querySelector(PERMALINK_SELECTOR); let tweetId = timeEl?.parentElement?.href; if (tweetId && tweetId.includes('/status/')) { const match = tweetId.match(/\/status\/(\d+)/); if (match && match[1]) { return match[1]; } return tweetId.substring(tweetId.indexOf('/status/') + 1); } return `tweet-${Math.random().toString(36).substring(2, 15)}-${Date.now()}`; } /** * Extracts the Twitter handle from a tweet article element. * @param {Element} tweetArticle - The tweet article element. * @returns {array} The user and quoted user handles. */ function getUserHandles(tweetArticle) { let handles = []; // Extract the main author's handle - take only the first one const handleElement = tweetArticle.querySelector(USER_HANDLE_SELECTOR); if (handleElement) { const href = handleElement.getAttribute('href'); if (href && href.startsWith('/')) { handles.push(href.slice(1)); } } // If we have the main author's handle, try to get the quoted author if (handles.length > 0) { const quoteContainer = tweetArticle.querySelector('div[role="link"][tabindex="0"]'); if (quoteContainer) { // Look for a div with data-testid="UserAvatar-Container-username" const userAvatarDiv = quoteContainer.querySelector('div[data-testid^="UserAvatar-Container-"]'); if (userAvatarDiv) { const testId = userAvatarDiv.getAttribute('data-testid'); // Extract username from the data-testid attribute (part after the last dash) const lastDashIndex = testId.lastIndexOf('-'); if (lastDashIndex >= 0 && lastDashIndex < testId.length - 1) { const quotedHandle = testId.substring(lastDashIndex + 1); if (quotedHandle && quotedHandle !== handles[0]) { handles.push(quotedHandle); } } // Fallback: try to extract handle from status link const quotedLink = quoteContainer.querySelector('a[href*="/status/"]'); if (quotedLink) { const href = quotedLink.getAttribute('href'); // Extract username from URL structure /username/status/id const match = href.match(/^\/([^/]+)\/status\/\d+/); if (match && match[1] && match[1] !== handles[0]) { handles.push(match[1]); } } } } } // Return non-empty array or [''] if no handles found return handles.length > 0 ? handles : ['']; } /** * Extracts and returns an array of media URLs from the tweet element. * @param {Element} scopeElement - The tweet element. * @returns {string[]} An array of media URLs. */ function extractMediaLinks(scopeElement) { if (!scopeElement) return []; const mediaLinks = new Set(); // Find all images and videos in the tweet const imgSelector = `${MEDIA_IMG_SELECTOR}, [data-testid="tweetPhoto"] img, img[src*="pbs.twimg.com/media"]`; const videoSelector = `${MEDIA_VIDEO_SELECTOR}, video[poster*="pbs.twimg.com"], video`; // First try the standard selectors let mediaElements = scopeElement.querySelectorAll(`${imgSelector}, ${videoSelector}`); // If no media found and this is a quoted tweet, try more aggressive selectors if (mediaElements.length === 0 && scopeElement.matches(QUOTE_CONTAINER_SELECTOR)) { // Try to find any image within the quoted tweet mediaElements = scopeElement.querySelectorAll('img[src*="pbs.twimg.com"], video[poster*="pbs.twimg.com"]'); } mediaElements.forEach(mediaEl => { // Get the source URL (src for images, poster for videos) const sourceUrl = mediaEl.tagName === 'IMG' ? mediaEl.src : mediaEl.poster; // Skip if not a Twitter media URL or if undefined or if it's a profile image if (!sourceUrl || !(sourceUrl.includes('pbs.twimg.com/')) || sourceUrl.includes('profile_images')) { return; } try { // Parse the URL to handle format parameters const url = new URL(sourceUrl); const format = url.searchParams.get('format'); const name = url.searchParams.get('name'); // 'small', 'medium', 'large', etc. // Create the final URL with the right format and size let finalUrl = sourceUrl; // Try to get the original size by removing size indicator if (name && name !== 'orig') { // Replace format=jpg&name=small with format=jpg&name=orig finalUrl = sourceUrl.replace(`name=${name}`, 'name=orig'); } mediaLinks.add(finalUrl); } catch (error) { // Fallback: just add the raw URL as is mediaLinks.add(sourceUrl); } }); return Array.from(mediaLinks); } // ----- Rating Indicator Functions ----- /** * Processes a single tweet after a delay. * It first sets a pending indicator, then either applies a cached rating, * or calls the API to rate the tweet (with retry logic). * Finally, it applies the filtering logic. * @param {Element} tweetArticle - The tweet element. * @param {string} tweetId - The tweet ID. */ // Helper function to determine if a tweet is the original tweet in a conversation. // We check if the tweet article has a following sibling with data-testid="inline_reply_offscreen". function isOriginalTweet(tweetArticle) { let sibling = tweetArticle.nextElementSibling; while (sibling) { if (sibling.matches && sibling.matches('div[data-testid="inline_reply_offscreen"]')) { return true; } sibling = sibling.nextElementSibling; } return false; } // ----- MutationObserver Setup ----- /** * Handles DOM mutations to detect new tweets added to the timeline. * @param {MutationRecord[]} mutationsList - List of observed mutations. */ function handleMutations(mutationsList) { let tweetsAdded = false; for (const mutation of mutationsList) { handleThreads(); if (mutation.type === 'childList') { // Process added nodes if (mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { if (node.matches && node.matches(TWEET_ARTICLE_SELECTOR)) { scheduleTweetProcessing(node); tweetsAdded = true; } else if (node.querySelectorAll) { const tweetsInside = node.querySelectorAll(TWEET_ARTICLE_SELECTOR); if (tweetsInside.length > 0) { tweetsInside.forEach(scheduleTweetProcessing); tweetsAdded = true; } } } }); } // Process removed nodes to clean up description elements if (mutation.removedNodes.length > 0) { mutation.removedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if the removed node is a tweet article or contains tweet articles const isTweet = node.matches && node.matches(TWEET_ARTICLE_SELECTOR); const removedTweets = isTweet ? [node] : (node.querySelectorAll ? Array.from(node.querySelectorAll(TWEET_ARTICLE_SELECTOR)) : []); // For each removed tweet, find and remove its description element removedTweets.forEach(tweet => { const indicator = tweet.querySelector('.score-indicator'); if (indicator && indicator.dataset.id) { const descId = 'desc-' + indicator.dataset.id; const descBox = document.getElementById(descId); if (descBox) { descBox.remove(); } } }); } }); } } } // If any tweets were added, ensure filtering is applied if (tweetsAdded) { // Apply a small delay to allow processing to start first setTimeout(() => { applyFilteringToAll(); }, 100); } } // ----- ratingEngine.js ----- /** * Applies filtering to a single tweet by hiding it if its score is below the threshold. * Also updates the rating indicator. * @param {Element} tweetArticle - The tweet element. */ function filterSingleTweet(tweetArticle) { const score = parseInt(tweetArticle.dataset.sloppinessScore || '1', 10); // Update the indicator based on the tweet's rating status setScoreIndicator(tweetArticle, score, tweetArticle.dataset.ratingStatus || 'rated', tweetArticle.dataset.ratingDescription); // If the tweet is still pending a rating, keep it visible // Always get the latest threshold directly from storage const currentFilterThreshold = parseInt(GM_getValue('filterThreshold', '1')); if (tweetArticle.dataset.ratingStatus === 'pending' || tweetArticle.dataset.ratingStatus === 'streaming') { //tweetArticle.style.display = ''; tweetArticle.closest('div[data-testid="cellInnerDiv"]').style.display = ''; } else if (isNaN(score) || score < currentFilterThreshold) { //tweetArticle.style.display = 'none'; tweetArticle.closest('div[data-testid="cellInnerDiv"]').style.display = 'none'; } else { //tweetArticle.style.display = ''; tweetArticle.closest('div[data-testid="cellInnerDiv"]').style.display = ''; } } /** * Applies a cached rating (if available) to a tweet article. * Also sets the rating status to 'rated' and updates the indicator. * @param {Element} tweetArticle - The tweet element. * @returns {boolean} True if a cached rating was applied. */ function applyTweetCachedRating(tweetArticle) { const tweetId = getTweetID(tweetArticle); const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; // Blacklisted users are automatically given a score of 10 if (userHandle && isUserBlacklisted(userHandle)) { tweetArticle.dataset.sloppinessScore = '10'; tweetArticle.dataset.blacklisted = 'true'; tweetArticle.dataset.ratingStatus = 'blacklisted'; tweetArticle.dataset.ratingDescription = 'Whitelisted user'; setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is whitelisted"); filterSingleTweet(tweetArticle); return true; } // Check ID-based cache if (tweetIDRatingCache[tweetId]) { // Skip incomplete streaming entries that don't have a score yet if (tweetIDRatingCache[tweetId].streaming === true && (tweetIDRatingCache[tweetId].score === undefined || tweetIDRatingCache[tweetId].score === null)) { return false; } // Ensure the score exists before applying it if (tweetIDRatingCache[tweetId].score !== undefined && tweetIDRatingCache[tweetId].score !== null) { const score = tweetIDRatingCache[tweetId].score; const desc = tweetIDRatingCache[tweetId].description; const reasoning = tweetIDRatingCache[tweetId].reasoning || ""; tweetArticle.dataset.sloppinessScore = score.toString(); tweetArticle.dataset.cachedRating = 'true'; if (reasoning) { tweetArticle.dataset.ratingReasoning = reasoning; } // If it's a streaming entry that's not complete, mark as streaming instead of cached if (tweetIDRatingCache[tweetId].streaming === true) { tweetArticle.dataset.ratingStatus = 'streaming'; setScoreIndicator(tweetArticle, score, 'streaming', desc); } else { // Check if this rating is from storage (cached) or newly created const isFromStorage = tweetIDRatingCache[tweetId].fromStorage === true; // Set status based on source if (isFromStorage) { tweetArticle.dataset.ratingStatus = 'cached'; setScoreIndicator(tweetArticle, score, 'cached', desc); } else { tweetArticle.dataset.ratingStatus = 'rated'; setScoreIndicator(tweetArticle, score, 'rated', desc); } } tweetArticle.dataset.ratingDescription = desc; filterSingleTweet(tweetArticle); return true; } else if (!tweetIDRatingCache[tweetId].streaming){ // Invalid cache entry - missing score delete tweetIDRatingCache[tweetId]; // Remove invalid entry saveTweetRatings(); return false; } } return false; } // ----- UI Helper Functions ----- /** * Saves the tweet ratings (by tweet ID) to persistent storage and updates the UI. */ function saveTweetRatings() { GM_setValue('tweetRatings', JSON.stringify(tweetIDRatingCache)); // Dynamically update the UI cache stats counter // Only try to update if the element exists (the settings panel is open) const cachedCountEl = document.getElementById('cached-ratings-count'); if (cachedCountEl) { cachedCountEl.textContent = Object.keys(tweetIDRatingCache).length; } // Also update the cache stats in the settings panel try { // Use the UI function if it's available if (typeof updateCacheStatsUI === 'function') { updateCacheStatsUI(); } } catch (e) { } } /** * Checks if a given user handle is in the blacklist. * @param {string} handle - The Twitter handle. * @returns {boolean} True if blacklisted, false otherwise. */ function isUserBlacklisted(handle) { if (!handle) return false; handle = handle.toLowerCase().trim(); return blacklistedHandles.some(h => h.toLowerCase().trim() === handle); } async function delayedProcessTweet(tweetArticle, tweetId) { const apiKey = GM_getValue('openrouter-api-key', ''); if (!apiKey) { tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "No API key"; try { setScoreIndicator(tweetArticle, 9, 'error', "No API key"); // Verify indicator was actually created if (!tweetArticle.querySelector('.score-indicator')) { } } catch (e) { } filterSingleTweet(tweetArticle); // Remove from processedTweets to allow retrying processedTweets.delete(tweetId); return; } let score = 5; // Default score if rating fails let description = ""; let processingSuccessful = false; try { // Get user handle const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; const quotedHandle = handles.length > 1 ? handles[1] : ''; // Check if tweet's author is blacklisted (fast path) if (userHandle && isUserBlacklisted(userHandle)) { tweetArticle.dataset.sloppinessScore = '10'; tweetArticle.dataset.blacklisted = 'true'; tweetArticle.dataset.ratingStatus = 'blacklisted'; tweetArticle.dataset.ratingDescription = "Blacklisted user"; try { setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is blacklisted"); // Verify indicator was actually created if (!tweetArticle.querySelector('.score-indicator')) { throw new Error("Failed to create score indicator"); } } catch (e) { // Even if indicator fails, we've set the dataset properties } filterSingleTweet(tweetArticle); processingSuccessful = true; } // Check for a cached rating, but only use it if it has a valid score // and is not an incomplete streaming entry if (tweetIDRatingCache[tweetId]) { const cacheEntry = tweetIDRatingCache[tweetId]; const isValidCacheEntry = cacheEntry.score !== undefined && cacheEntry.score !== null && !(cacheEntry.streaming === true && cacheEntry.score === undefined); if (isValidCacheEntry) { const cacheApplied = applyTweetCachedRating(tweetArticle); if (cacheApplied) { // Verify the indicator exists after applying cached rating if (!tweetArticle.querySelector('.score-indicator')) { processingSuccessful = false; } else { processingSuccessful = true; } return; } } else if (cacheEntry.streaming === true) { // This is a streaming entry that's still in progress // Don't delete it, but don't use it either } else { // Invalid cache entry, delete it delete tweetIDRatingCache[tweetId]; saveTweetRatings(); } } const fullContextWithImageDescription = await getFullContext(tweetArticle, tweetId, apiKey); if (!fullContextWithImageDescription) { throw new Error("Failed to get tweet context"); } // Add thread relationship context const replyInfo = getTweetReplyInfo(tweetId); if (replyInfo && replyInfo.replyTo) { // Add thread context to cache entry if we process this tweet if (!tweetIDRatingCache[tweetId]) { tweetIDRatingCache[tweetId] = {}; } if (!tweetIDRatingCache[tweetId].threadContext) { tweetIDRatingCache[tweetId].threadContext = { replyTo: replyInfo.to, replyToId: replyInfo.replyTo, isRoot: false }; } } //Get the media URLS from the entire fullContextWithImageDescription, and pass that to the rating engine //This allows us to get the media links from the thread history as well const mediaURLs = []; // Extract regular media URLs const mediaMatches = fullContextWithImageDescription.match(/\[MEDIA_URLS\]:\s*\n(.*?)(?:\n|$)/); if (mediaMatches && mediaMatches[1]) { mediaURLs.push(...mediaMatches[1].split(', ')); } // Extract quoted tweet media URLs const quotedMediaMatches = fullContextWithImageDescription.match(/\[QUOTED_TWEET_MEDIA_URLS\]:\s*\n(.*?)(?:\n|$)/); if (quotedMediaMatches && quotedMediaMatches[1]) { mediaURLs.push(...quotedMediaMatches[1].split(', ')); } // --- API Call or Fallback --- if (apiKey && fullContextWithImageDescription) { try { // Check if there's already a complete entry in the cache before calling the API const isCached = tweetIDRatingCache[tweetId] && !tweetIDRatingCache[tweetId].streaming && tweetIDRatingCache[tweetId].score !== undefined; const rating = await rateTweetWithOpenRouter(fullContextWithImageDescription, tweetId, apiKey, mediaURLs); score = rating.score; description = rating.content; // Check if this rating was loaded from storage if (tweetIDRatingCache[tweetId] && tweetIDRatingCache[tweetId].fromStorage === true) { // If it was loaded from storage, mark it as cached tweetArticle.dataset.ratingStatus = 'cached'; } else { // Otherwise use the normal logic tweetArticle.dataset.ratingStatus = rating.error ? 'error' : (isCached || rating.cached ? 'cached' : 'rated'); } tweetArticle.dataset.ratingDescription = description || "not available"; tweetArticle.dataset.sloppinessScore = score.toString(); if (!isUserBlacklisted(userHandle)){ try { setScoreIndicator(tweetArticle, score, tweetArticle.dataset.ratingStatus, tweetArticle.dataset.ratingDescription); // Verify the indicator exists if (!tweetArticle.querySelector('.score-indicator')) { throw new Error("Failed to create score indicator"); } // Log indicator classes after setting } catch (e) { // Continue even if indicator fails - we've set the dataset properties } filterSingleTweet(tweetArticle); } processingSuccessful = !rating.error; // Store the full context after rating is complete if (!rating.error) { if (tweetIDRatingCache[tweetId]) { tweetIDRatingCache[tweetId].score = score; tweetIDRatingCache[tweetId].description = description; tweetIDRatingCache[tweetId].tweetContent = fullContextWithImageDescription; tweetIDRatingCache[tweetId].streaming = false; // Mark as complete } else { tweetIDRatingCache[tweetId] = { score: score, description: description, tweetContent: fullContextWithImageDescription, streaming: false // Mark as complete }; } // Save ratings to persistent storage saveTweetRatings(); } else { // On error, remove any existing cache entry to allow retry if (tweetIDRatingCache[tweetId]) { delete tweetIDRatingCache[tweetId]; saveTweetRatings(); } } } catch (apiError) { score = 10; // Fallback to a random score tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "API error"; // Don't consider API errors as successful processing processingSuccessful = false; } } else if (fullContextWithImageDescription) { score = 10; //show all tweets that errored tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "No API key"; processingSuccessful = true; } else { //show all tweets that errored score = 10; tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "No content"; processingSuccessful = true; } // Always ensure a valid score is set if (score === undefined || score === null) { score = 5; } tweetArticle.dataset.sloppinessScore = score.toString(); try { //group should default to closed setScoreIndicator(tweetArticle, score, tweetArticle.dataset.ratingStatus, tweetArticle.dataset.ratingDescription || ""); // Final verification of indicator if (!tweetArticle.querySelector('.score-indicator')) { processingSuccessful = false; } } catch (e) { processingSuccessful = false; } filterSingleTweet(tweetArticle); } catch (error) { if (!tweetArticle.dataset.sloppinessScore) { tweetArticle.dataset.sloppinessScore = '5'; tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "error processing tweet"; try { setScoreIndicator(tweetArticle, 5, 'error', 'Error processing tweet'); // Verify indicator exists if (!tweetArticle.querySelector('.score-indicator')) { } } catch (e) { } filterSingleTweet(tweetArticle); } processingSuccessful = false; } finally { // If processing was not successful, remove from processedTweets // to allow future retry attempts if (!processingSuccessful) { processedTweets.delete(tweetId); } } } /** * Schedules processing of a tweet if it hasn't been processed yet. * @param {Element} tweetArticle - The tweet element. */ function scheduleTweetProcessing(tweetArticle) { // First, ensure the tweet has a valid ID const tweetId = getTweetID(tweetArticle); if (!tweetId) { return; } // Fast-path: if author is blacklisted, assign score immediately const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; if (userHandle && isUserBlacklisted(userHandle)) { tweetArticle.dataset.sloppinessScore = '10'; tweetArticle.dataset.blacklisted = 'true'; tweetArticle.dataset.ratingStatus = 'blacklisted'; tweetArticle.dataset.ratingDescription = "Whitelisted user"; setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is whitelisted"); filterSingleTweet(tweetArticle); return; } // Check for a cached rating, but be careful with streaming cache entries if (tweetIDRatingCache[tweetId]) { // Only apply cached rating if it has a valid score and isn't an incomplete streaming entry const isIncompleteStreaming = tweetIDRatingCache[tweetId].streaming === true && (tweetIDRatingCache[tweetId].score === undefined || tweetIDRatingCache[tweetId].score === null); if (!isIncompleteStreaming) { const wasApplied = applyTweetCachedRating(tweetArticle); if (wasApplied) { // Force redraw filter to ensure the tweet is properly filtered filterSingleTweet(tweetArticle); return; } } } // Skip if already processed in this session if (processedTweets.has(tweetId)) { // Verify that the tweet actually has an indicator - if not, remove from processed const hasIndicator = !!tweetArticle.querySelector('.score-indicator'); if (!hasIndicator) { processedTweets.delete(tweetId); } else { return; } } // Immediately mark as pending before scheduling actual processing if (!processedTweets.has(tweetId)) { processedTweets.add(tweetId); } tweetArticle.dataset.ratingStatus = 'pending'; // Ensure indicator is set try { setScoreIndicator(tweetArticle, null, 'pending'); } catch (e) { } // Now schedule the actual rating processing setTimeout(() => { try { delayedProcessTweet(tweetArticle, tweetId); } catch (e) { processedTweets.delete(tweetId); } }, PROCESSING_DELAY_MS); } // Add this near the beginning of the file with other global variables // Store reply relationships across sessions let threadRelationships = {}; let lastThreadCheck = 0; const THREAD_CHECK_INTERVAL = 2000; // 2 seconds between thread checks let threadMappingInProgress = false; // Add a memory-based flag for more reliable state tracking // Load thread relationships from storage on script initialization function loadThreadRelationships() { try { const savedRelationships = GM_getValue('threadRelationships', '{}'); threadRelationships = JSON.parse(savedRelationships); } catch (e) { threadRelationships = {}; } } // Save thread relationships to persistent storage function saveThreadRelationships() { try { // Limit size to prevent storage issues const relationshipCount = Object.keys(threadRelationships).length; if (relationshipCount > 1000) { // If over 1000, keep only the most recent 500 const entries = Object.entries(threadRelationships); // Sort by timestamp if available, otherwise keep newest entries by default key order entries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0)); const recent = entries.slice(0, 500); threadRelationships = Object.fromEntries(recent); } GM_setValue('threadRelationships', JSON.stringify(threadRelationships)); } catch (e) { } } // Initialize thread relationships on load loadThreadRelationships(); // Add this function to build a complete chain of replies async function buildReplyChain(tweetId, maxDepth = 5) { if (!tweetId || maxDepth <= 0) return []; // Start with empty chain const chain = []; // Current tweet ID to process let currentId = tweetId; let depth = 0; // Traverse up the chain recursively while (currentId && depth < maxDepth) { const replyInfo = threadRelationships[currentId]; if (!replyInfo || !replyInfo.replyTo) break; // Add this link in the chain chain.push({ fromId: currentId, toId: replyInfo.replyTo, from: replyInfo.from, to: replyInfo.to }); // Move up the chain currentId = replyInfo.replyTo; depth++; } return chain; } /** * Extracts the full context of a tweet article and returns a formatted string. * * Schema: * [TWEET]: * @[the author of the tweet] * [the text of the tweet] * [MEDIA_DESCRIPTION]: * [IMAGE 1]: [description], [IMAGE 2]: [description], etc. * [QUOTED_TWEET]: * [the text of the quoted tweet] * [QUOTED_TWEET_MEDIA_DESCRIPTION]: * [IMAGE 1]: [description], [IMAGE 2]: [description], etc. * * @param {Element} tweetArticle - The tweet article element. * @param {string} tweetId - The tweet's ID. * @param {string} apiKey - API key used for getting image descriptions. * @returns {Promise<string>} - The full context string. */ async function getFullContext(tweetArticle, tweetId, apiKey) { const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; const quotedHandle = handles.length > 1 ? handles[1] : ''; // --- Extract Main Tweet Content --- const mainText = getElementText(tweetArticle.querySelector(TWEET_TEXT_SELECTOR)); // Allow a small delay for images to load await new Promise(resolve => setTimeout(resolve, 10)); let allMediaLinks = extractMediaLinks(tweetArticle); // --- Extract Quoted Tweet Content (if any) --- let quotedText = ""; let quotedMediaLinks = []; let quotedTweetId = null; const quoteContainer = tweetArticle.querySelector(QUOTE_CONTAINER_SELECTOR); if (quoteContainer) { // Try to get the quoted tweet ID from the link const quotedLink = quoteContainer.querySelector('a[href*="/status/"]'); if (quotedLink) { const href = quotedLink.getAttribute('href'); const match = href.match(/\/status\/(\d+)/); if (match && match[1]) { quotedTweetId = match[1]; } } quotedText = getElementText(quoteContainer.querySelector(TWEET_TEXT_SELECTOR)) || ""; // Short delay to ensure quoted tweet images are loaded await new Promise(resolve => setTimeout(resolve, 20)); quotedMediaLinks = extractMediaLinks(quoteContainer); } // Get thread media URLs from cache if available const conversation = document.querySelector('div[aria-label="Timeline: Conversation"]') || document.querySelector('div[aria-label^="Timeline: Conversation"]'); let threadMediaUrls = []; if (conversation && conversation.dataset.threadMapping && tweetIDRatingCache[tweetId]?.threadContext?.threadMediaUrls) { // Get thread media URLs from cache if available threadMediaUrls = tweetIDRatingCache[tweetId].threadContext.threadMediaUrls || []; } else if (conversation && conversation.dataset.threadMediaUrls) { // Or get them from the dataset if available try { const allMediaUrls = JSON.parse(conversation.dataset.threadMediaUrls); threadMediaUrls = Array.isArray(allMediaUrls) ? allMediaUrls : []; } catch (e) { } } // Combine all media URLs: current tweet + quoted tweet + thread context let allAvailableMediaLinks = [...allMediaLinks]; // Remove any media links from the main tweet that also appear in the quoted tweet let mainMediaLinks = allAvailableMediaLinks.filter(link => !quotedMediaLinks.includes(link)); // Start building the context let fullContextWithImageDescription = `[TWEET ${tweetId}] Author:@${userHandle}: ` + mainText; // Add media from the current tweet if (mainMediaLinks.length > 0) { // Process main tweet images only if image descriptions are enabled if (enableImageDescriptions = GM_getValue('enableImageDescriptions', false)) { let mainMediaLinksDescription = await getImageDescription(mainMediaLinks, apiKey, tweetId, userHandle); fullContextWithImageDescription += ` [MEDIA_DESCRIPTION]: ${mainMediaLinksDescription}`; } // Just add the URLs when descriptions are disabled fullContextWithImageDescription += ` [MEDIA_URLS]: ${mainMediaLinks.join(", ")}`; } // Add thread media URLs if this is a reply and we have previous media if (!isOriginalTweet(tweetArticle) && threadMediaUrls.length > 0) { // Filter out duplicates const uniqueThreadMediaUrls = threadMediaUrls.filter(url => !mainMediaLinks.includes(url) && !quotedMediaLinks.includes(url)); if (uniqueThreadMediaUrls.length > 0) { fullContextWithImageDescription += ` [THREAD_MEDIA_URLS]: ${uniqueThreadMediaUrls.join(", ")}`; } } // --- Quoted Tweet Handling --- if (quotedText || quotedMediaLinks.length > 0) { fullContextWithImageDescription += ` [QUOTED_TWEET${quotedTweetId ? ' ' + quotedTweetId : ''}]: Author:@${quotedHandle}: ${quotedText}`; if (quotedMediaLinks.length > 0) { // Process quoted tweet images only if image descriptions are enabled if (enableImageDescriptions) { let quotedMediaLinksDescription = await getImageDescription(quotedMediaLinks, apiKey, tweetId, userHandle); fullContextWithImageDescription += ` [QUOTED_TWEET_MEDIA_DESCRIPTION]: ${quotedMediaLinksDescription}`; } // Just add the URLs when descriptions are disabled fullContextWithImageDescription += ` [QUOTED_TWEET_MEDIA_URLS]: ${quotedMediaLinks.join(", ")}`; } } // --- Get complete reply chain using persistent relationships --- const replyChain = await buildReplyChain(tweetId); // --- Conversation Thread Handling --- let threadHistoryIncluded = false; if (conversation && conversation.dataset.threadHist) { // If this tweet is not the original tweet, prepend the thread history. if (!isOriginalTweet(tweetArticle)) { fullContextWithImageDescription = conversation.dataset.threadHist + ` [REPLY] ` + fullContextWithImageDescription; threadHistoryIncluded = true; } } // Add recursive reply chain information if available and not already included in thread history if (replyChain.length > 0 && !threadHistoryIncluded) { let replyChainText = '\n[REPLY CHAIN]\n'; for (let i = replyChain.length - 1; i >= 0; i--) { const link = replyChain[i]; replyChainText += `Tweet ${link.fromId} by @${link.from || 'unknown'} is a reply to tweet ${link.toId} by @${link.to || 'unknown'}\n`; } fullContextWithImageDescription = replyChainText + fullContextWithImageDescription; } // Individual reply marker if needed const replyInfo = getTweetReplyInfo(tweetId); if (replyInfo && replyInfo.replyTo && !threadHistoryIncluded && replyChain.length === 0) { fullContextWithImageDescription = `[REPLY TO TWEET ${replyInfo.replyTo}]\n` + fullContextWithImageDescription; } tweetArticle.dataset.fullContext = fullContextWithImageDescription; return fullContextWithImageDescription; } /** * Applies filtering to all tweets currently in the observed container. */ function applyFilteringToAll() { if (!observedTargetNode) return; const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR); tweets.forEach(filterSingleTweet); } function ensureAllTweetsRated() { if (!observedTargetNode) return; const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR); if (tweets.length > 0) { let unreatedCount = 0; tweets.forEach(tweet => { const tweetId = getTweetID(tweet); if (!tweetId) return; // Skip tweets without a valid ID // Check for any issues that would require processing: // 1. No score data attribute // 2. Error status // 3. Missing indicator element (even if in processedTweets) const hasScore = !!tweet.dataset.sloppinessScore; const hasError = tweet.dataset.ratingStatus === 'error'; const hasIndicator = !!tweet.querySelector('.score-indicator'); const isStreaming = tweet.dataset.ratingStatus === 'streaming'; // If tweet is in processedTweets but missing indicator, remove it from processed if (processedTweets.has(tweetId) && !hasIndicator) { processedTweets.delete(tweetId); } // Schedule processing if needed and not already in progress const needsProcessing = (!hasScore && !isStreaming) || hasError || !hasIndicator; if (needsProcessing && !processedTweets.has(tweetId)) { unreatedCount++; const status = !hasIndicator ? 'missing indicator' : !hasScore ? 'unrated' : hasError ? 'error' : 'unknown issue'; scheduleTweetProcessing(tweet); } }); if (unreatedCount > 0) { } } } async function handleThreads() { try { // Don't check too frequently const now = Date.now(); if (now - lastThreadCheck < THREAD_CHECK_INTERVAL) { return; } lastThreadCheck = now; // Find the conversation timeline using a more specific selector let conversation = document.querySelector('div[aria-label="Timeline: Conversation"]'); if (!conversation) { conversation = document.querySelector('div[aria-label^="Timeline: Conversation"]'); } if (!conversation) return; // More reliable state checking with both DOM and memory-based flags if (threadMappingInProgress || conversation.dataset.threadHist === "pending") { return; // Don't interrupt pending operations } // Add protection to avoid re-processing if we already mapped this thread recently if (conversation.dataset.threadMappedAt) { const lastMappedTime = parseInt(conversation.dataset.threadMappedAt, 10); // If we've mapped this thread in the last 10 seconds, skip if (now - lastMappedTime < 10000) { return; } } // Extract the root tweet ID from the URL for improved thread mapping const match = location.pathname.match(/status\/(\d+)/); const localRootTweetId = match ? match[1] : null; if (!localRootTweetId) return; // Only proceed if we can identify the root tweet // Initialize thread history if (conversation.dataset.threadHist === undefined) { // Original behavior - initialize thread history threadHist = ""; const firstArticle = document.querySelector('article[data-testid="tweet"]'); if (firstArticle) { conversation.dataset.threadHist = 'pending'; threadMappingInProgress = true; // Set memory-based flag try { const tweetId = getTweetID(firstArticle); if (!tweetId) { throw new Error("Failed to get tweet ID from first article"); } // Get the full context of the root tweet const apiKey = GM_getValue('openrouter-api-key', ''); const fullcxt = await getFullContext(firstArticle, tweetId, apiKey); if (!fullcxt) { throw new Error("Failed to get full context for root tweet"); } threadHist = fullcxt; conversation.dataset.threadHist = threadHist; if (conversation.firstChild) { conversation.firstChild.dataset.canary = "true"; } // Schedule processing for the original tweet if (!processedTweets.has(tweetId)) { scheduleTweetProcessing(firstArticle); } // Use improved thread detection to map the structure setTimeout(() => { mapThreadStructure(conversation, localRootTweetId); }, 500); } catch (error) { // Clean up on error threadMappingInProgress = false; delete conversation.dataset.threadHist; } return; } } else if (conversation.dataset.threadHist !== "pending" && conversation.firstChild && conversation.firstChild.dataset.canary === undefined) { // Original behavior for deep-diving into replies if (conversation.firstChild) { conversation.firstChild.dataset.canary = "pending"; } threadMappingInProgress = true; // Set memory-based flag try { const nextArticle = document.querySelector('article[data-testid="tweet"]:has(~ div[data-testid="inline_reply_offscreen"])'); if (nextArticle) { const tweetId = getTweetID(nextArticle); if (!tweetId) { throw new Error("Failed to get tweet ID from next article"); } if (tweetIDRatingCache[tweetId] && tweetIDRatingCache[tweetId].tweetContent) { threadHist = threadHist + "\n[REPLY]\n" + tweetIDRatingCache[tweetId].tweetContent; } else { const apiKey = GM_getValue('openrouter-api-key', ''); await new Promise(resolve => setTimeout(resolve, 100)); const newContext = await getFullContext(nextArticle, tweetId, apiKey); if (!newContext) { throw new Error("Failed to get context for next article"); } threadHist = threadHist + "\n[REPLY]\n" + newContext; } conversation.dataset.threadHist = threadHist; } // Map thread structure after updating history setTimeout(() => { mapThreadStructure(conversation, localRootTweetId); }, 500); } catch (error) { // Clean up on error threadMappingInProgress = false; if (conversation.firstChild) { delete conversation.firstChild.dataset.canary; } } } else if (!threadMappingInProgress && !conversation.dataset.threadMappingInProgress) { // Run thread mapping periodically to catch new tweets loaded during scrolling threadMappingInProgress = true; // Set memory-based flag setTimeout(() => { mapThreadStructure(conversation, localRootTweetId); }, 500); } } catch (error) { // Clean up all state on error threadMappingInProgress = false; } } // Enhance the thread mapping to associate usernames with tweet IDs async function mapThreadStructure(conversation, localRootTweetId) { // Mark mapping in progress to prevent duplicate processing conversation.dataset.threadMappingInProgress = "true"; conversation.dataset.threadMappedAt = Date.now().toString(); threadMappingInProgress = true; // Set memory-based flag try { // Use a timeout promise to prevent hanging const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Thread mapping timed out')), 5000) ); // The actual mapping function const mapping = async () => { // Process all visible tweets using the cellInnerDiv structure for improved mapping let cellDivs = Array.from(document.querySelectorAll('div[data-testid="cellInnerDiv"]')); if (!cellDivs.length) { delete conversation.dataset.threadMappingInProgress; threadMappingInProgress = false; return; } let tweetCells = []; let processedCount = 0; // First pass: collect all tweet data for (let idx = 0; idx < cellDivs.length; idx++) { const cell = cellDivs[idx]; const article = cell.querySelector('article[data-testid="tweet"]'); if (!article) continue; // Extract tweet metadata with proper error handling try { let tweetId = getTweetID(article); if (!tweetId) { let tweetLink = article.querySelector('a[href*="/status/"]'); if (tweetLink) { let match = tweetLink.href.match(/status\/(\d+)/); if (match) tweetId = match[1]; } } // Skip if we still can't get a tweet ID if (!tweetId) continue; // Extract username using existing functions if available const handles = getUserHandles(article); let username = handles.length > 0 ? handles[0] : null; // Skip if we can't get a username if (!username) continue; // Extract tweet text let tweetTextSpan = article.querySelector('[data-testid="tweetText"]'); let text = tweetTextSpan ? tweetTextSpan.innerText.trim().replace(/\n+/g, ' ⏎ ') : ''; // Extract media links from this tweet let mediaLinks = extractMediaLinks(article); // Extract quoted tweet media if any let quotedMediaLinks = []; const quoteContainer = article.querySelector(QUOTE_CONTAINER_SELECTOR); if (quoteContainer) { quotedMediaLinks = extractMediaLinks(quoteContainer); } // Detect reply structure based on DOM let prevCell = cellDivs[idx - 1] || null; let isReplyToRoot = false; if (prevCell && prevCell.childElementCount === 1) { let onlyChild = prevCell.children[0]; if (onlyChild && onlyChild.children.length === 0 && onlyChild.innerHTML.trim() === '') { isReplyToRoot = true; } } tweetCells.push({ tweetNode: article, username, tweetId, text, mediaLinks, quotedMediaLinks, cellIndex: idx, isReplyToRoot, cellDiv: cell, index: processedCount++ }); // Schedule processing for this tweet if not already processed if (!processedTweets.has(tweetId)) { scheduleTweetProcessing(article); } } catch (err) { // Continue with next tweet continue; } } // Build reply structure only if we have tweets to process if (tweetCells.length === 0) { delete conversation.dataset.threadMappingInProgress; threadMappingInProgress = false; return; } // Second pass: build the reply structure with the right relationship chain for (let i = 0; i < tweetCells.length; ++i) { let tw = tweetCells[i]; if (tw.tweetId === localRootTweetId) { tw.replyTo = null; tw.isRoot = true; } else if (tw.isReplyToRoot) { let root = tweetCells.find(tk => tk.tweetId === localRootTweetId); tw.replyTo = root ? root.username : null; tw.replyToId = root ? root.tweetId : null; tw.isRoot = false; } else if (i > 0) { tw.replyTo = tweetCells[i - 1].username; tw.replyToId = tweetCells[i - 1].tweetId; tw.isRoot = false; } } // Create thread mapping with media URLs for context generation const replyDocs = tweetCells.map(tw => ({ from: tw.username, tweetId: tw.tweetId, to: tw.replyTo, toId: tw.replyToId, isRoot: tw.isRoot === true, text: tw.text, mediaLinks: tw.mediaLinks || [], quotedMediaLinks: tw.quotedMediaLinks || [] })); // Third pass: enhance with additional relationship information // If a tweet is a reply to another tweet not in this view, check // our persistent relationships to add that info for (let tw of tweetCells) { if (!tw.replyToId && !tw.isRoot && threadRelationships[tw.tweetId]?.replyTo) { // Found a reply relationship from persistent storage that isn't captured in this view tw.replyToId = threadRelationships[tw.tweetId].replyTo; tw.replyTo = threadRelationships[tw.tweetId].to; // Update the corresponding replyDoc const doc = replyDocs.find(d => d.tweetId === tw.tweetId); if (doc) { doc.toId = tw.replyToId; doc.to = tw.replyTo; } } } // Store the thread mapping in a dataset attribute for debugging conversation.dataset.threadMapping = JSON.stringify(replyDocs); // Update the global thread relationships const timestamp = Date.now(); replyDocs.forEach(doc => { if (doc.tweetId && doc.toId) { threadRelationships[doc.tweetId] = { replyTo: doc.toId, from: doc.from, to: doc.to, isRoot: false, timestamp }; } else if (doc.tweetId && doc.isRoot) { threadRelationships[doc.tweetId] = { replyTo: null, from: doc.from, isRoot: true, timestamp }; } }); // Save relationships to persistent storage saveThreadRelationships(); // Build thread history with full context including media links let completeThreadHistory = ""; // Start with the root post const rootTweet = replyDocs.find(t => t.isRoot === true); if (rootTweet && rootTweet.tweetId) { const rootTweetElement = tweetCells.find(t => t.tweetId === rootTweet.tweetId)?.tweetNode; if (rootTweetElement) { try { const apiKey = GM_getValue('openrouter-api-key', ''); const rootContext = await getFullContext(rootTweetElement, rootTweet.tweetId, apiKey); if (rootContext) { completeThreadHistory = rootContext; // Store the thread history in dataset for getFullContext to use conversation.dataset.threadHist = completeThreadHistory; // Also store the comprehensive media URLs from the entire thread const allMediaUrls = []; replyDocs.forEach(doc => { if (doc.mediaLinks && doc.mediaLinks.length) { allMediaUrls.push(...doc.mediaLinks); } if (doc.quotedMediaLinks && doc.quotedMediaLinks.length) { allMediaUrls.push(...doc.quotedMediaLinks); } }); if (allMediaUrls.length > 0) { conversation.dataset.threadMediaUrls = JSON.stringify(allMediaUrls); } } } catch (error) { // Continue processing even if full context fails } } } // Fourth pass: Update the cache with thread context // but with a limit on how many we process at once const batchSize = 10; for (let i = 0; i < replyDocs.length; i += batchSize) { const batch = replyDocs.slice(i, i + batchSize); batch.forEach(doc => { if (doc.tweetId && tweetIDRatingCache[doc.tweetId]) { tweetIDRatingCache[doc.tweetId].threadContext = { replyTo: doc.to, replyToId: doc.toId, isRoot: doc.isRoot, threadMediaUrls: doc.isRoot ? [] : getAllPreviousMediaUrls(doc.tweetId, replyDocs) }; // If this was just mapped, force reprocessing to use improved context if (doc.tweetId && processedTweets.has(doc.tweetId)) { // Find the corresponding tweet article from our collected tweet cells const tweetCell = tweetCells.find(tc => tc.tweetId === doc.tweetId); if (tweetCell && tweetCell.tweetNode) { // Don't reprocess if the tweet is currently streaming const isStreaming = tweetCell.tweetNode.dataset.ratingStatus === 'streaming' || (tweetIDRatingCache[doc.tweetId] && tweetIDRatingCache[doc.tweetId].streaming === true); if (!isStreaming) { processedTweets.delete(doc.tweetId); scheduleTweetProcessing(tweetCell.tweetNode); } } } } }); // Yield to main thread every batch to avoid locking UI if (i + batchSize < replyDocs.length) { await new Promise(resolve => setTimeout(resolve, 0)); } } // Mark mapping as complete delete conversation.dataset.threadMappingInProgress; threadMappingInProgress = false; }; // Helper function to get all media URLs from tweets that came before the current one in the thread function getAllPreviousMediaUrls(tweetId, replyDocs) { const allMediaUrls = []; const index = replyDocs.findIndex(doc => doc.tweetId === tweetId); if (index > 0) { // Get all media URLs from tweets before this one in the thread for (let i = 0; i < index; i++) { if (replyDocs[i].mediaLinks && replyDocs[i].mediaLinks.length) { allMediaUrls.push(...replyDocs[i].mediaLinks); } if (replyDocs[i].quotedMediaLinks && replyDocs[i].quotedMediaLinks.length) { allMediaUrls.push(...replyDocs[i].quotedMediaLinks); } } } return allMediaUrls; } // Race the mapping against the timeout await Promise.race([mapping(), timeout]); } catch (error) { // Clear the mapped timestamp and in-progress flag so we can try again later delete conversation.dataset.threadMappedAt; delete conversation.dataset.threadMappingInProgress; threadMappingInProgress = false; } } // For use in getFullContext to check if a tweet is a reply using persistent relationships function getTweetReplyInfo(tweetId) { if (threadRelationships[tweetId]) { return threadRelationships[tweetId]; } return null; } // ----- ui.js ----- // --- Utility Functions --- /** * Displays a temporary status message on the screen. * @param {string} message - The message to display. */ function showStatus(message) { const indicator = document.getElementById('status-indicator'); if (!indicator) { return; } indicator.textContent = message; indicator.classList.add('active'); setTimeout(() => { indicator.classList.remove('active'); }, 3000); } /** * Toggles the visibility of an element and updates the corresponding toggle button text. * @param {HTMLElement} element - The element to toggle. * @param {HTMLElement} toggleButton - The button that controls the toggle. * @param {string} openText - Text for the button when the element is open. * @param {string} closedText - Text for the button when the element is closed. */ function toggleElementVisibility(element, toggleButton, openText, closedText) { if (!element || !toggleButton) return; const isHidden = element.classList.toggle('hidden'); toggleButton.innerHTML = isHidden ? closedText : openText; // Special case for filter slider button (hide it when panel is shown) if (element.id === 'tweet-filter-container') { const filterToggle = document.getElementById('filter-toggle'); if (filterToggle) { filterToggle.style.display = isHidden ? 'block' : 'none'; } } } // --- Core UI Logic --- /** * Injects the UI elements from the HTML resource into the page. */ function injectUI() { //combined userscript has a const named MENU. If it exists, use it. let menuHTML; if(MENU){ menuHTML = MENU; }else{ menuHTML = GM_getValue('menuHTML'); } if (!menuHTML) { showStatus('Error: Could not load UI components.'); return null; } // Create a container to inject HTML const containerId = 'tweetfilter-root-container'; // Use the ID from the updated HTML let uiContainer = document.getElementById(containerId); if (uiContainer) { return uiContainer; // Return existing container } uiContainer = document.createElement('div'); uiContainer.id = containerId; uiContainer.innerHTML = menuHTML; // Inject styles const stylesheet = uiContainer.querySelector('style'); if (stylesheet) { GM_addStyle(stylesheet.textContent); stylesheet.remove(); // Remove style tag after injecting } else { } // Append the rest of the UI elements document.body.appendChild(uiContainer); // Set version number const versionInfo = uiContainer.querySelector('#version-info'); if (versionInfo) { versionInfo.textContent = `Twitter De-Sloppifier v${VERSION}`; } return uiContainer; // Return the newly created container } /** * Initializes all UI event listeners using event delegation. * @param {HTMLElement} uiContainer - The root container element for the UI. */ function initializeEventListeners(uiContainer) { if (!uiContainer) { return; } const settingsContainer = uiContainer.querySelector('#settings-container'); const filterContainer = uiContainer.querySelector('#tweet-filter-container'); const settingsToggleBtn = uiContainer.querySelector('#settings-toggle'); const filterToggleBtn = uiContainer.querySelector('#filter-toggle'); // --- Delegated Event Listener for Clicks --- uiContainer.addEventListener('click', (event) => { const target = event.target; const action = target.dataset.action; const setting = target.dataset.setting; const paramName = target.closest('.parameter-row')?.dataset.paramName; const tab = target.dataset.tab; const toggleTargetId = target.closest('[data-toggle]')?.dataset.toggle; // Button Actions if (action) { switch (action) { case 'close-filter': toggleElementVisibility(filterContainer, filterToggleBtn, 'Filter Slider', 'Filter Slider'); break; case 'close-settings': toggleElementVisibility(settingsContainer, settingsToggleBtn, '<span style="font-size: 14px;">✕</span> Close', '<span style="font-size: 14px;">⚙️</span> Settings'); break; case 'save-api-key': saveApiKey(); break; case 'clear-cache': clearTweetRatingsAndRefreshUI(); break; case 'export-settings': exportSettings(); break; case 'import-settings': importSettings(); break; case 'reset-settings': resetSettings(); break; case 'save-instructions': saveInstructions(); break; case 'add-handle': addHandleFromInput(); break; } } // Handle List Removal (delegated) if (target.classList.contains('remove-handle')) { const handleItem = target.closest('.handle-item'); const handleTextElement = handleItem?.querySelector('.handle-text'); if (handleTextElement) { const handle = handleTextElement.textContent.substring(1); // Remove '@' removeHandleFromBlacklist(handle); } } // Tab Switching if (tab) { switchTab(tab); } // Advanced Options Toggle if (toggleTargetId) { toggleAdvancedOptions(toggleTargetId); } }); // --- Delegated Event Listener for Input/Change --- uiContainer.addEventListener('input', (event) => { const target = event.target; const setting = target.dataset.setting; const paramName = target.closest('.parameter-row')?.dataset.paramName; // Settings Inputs / Toggles if (setting) { handleSettingChange(target, setting); } // Parameter Controls (Sliders/Number Inputs) if (paramName) { handleParameterChange(target, paramName); } // Filter Slider if (target.id === 'tweet-filter-slider') { handleFilterSliderChange(target); } }); uiContainer.addEventListener('change', (event) => { const target = event.target; const setting = target.dataset.setting; // Settings Inputs / Toggles (for selects like sort order) if (setting === 'modelSortOrder') { handleSettingChange(target, setting); fetchAvailableModels(); // Refresh models on sort change } // Settings Checkbox toggle (need change event for checkboxes) if (setting === 'enableImageDescriptions') { handleSettingChange(target, setting); } }); // --- Direct Event Listeners (Less common cases) --- // Settings Toggle Button if (settingsToggleBtn) { settingsToggleBtn.onclick = () => { toggleElementVisibility(settingsContainer, settingsToggleBtn, '<span style="font-size: 14px;">✕</span> Close', '<span style="font-size: 14px;">⚙️</span> Settings'); }; } // Filter Toggle Button if (filterToggleBtn) { filterToggleBtn.onclick = () => { // Ensure filter container is shown and button is hidden if (filterContainer) filterContainer.classList.remove('hidden'); filterToggleBtn.style.display = 'none'; }; } // Close custom selects when clicking outside document.addEventListener('click', closeAllSelectBoxes); } // --- Event Handlers --- /** Saves the API key from the input field. */ function saveApiKey() { const apiKeyInput = document.getElementById('openrouter-api-key'); const apiKey = apiKeyInput.value.trim(); let previousAPIKey = GM_getValue('openrouter-api-key', '').length>0?true:false; if (apiKey) { if (!previousAPIKey){ resetSettings(true); //jank hack to get the UI defaults to load correctly } GM_setValue('openrouter-api-key', apiKey); showStatus('API key saved successfully!'); fetchAvailableModels(); // Refresh model list //refresh the website location.reload(); } else { showStatus('Please enter a valid API key'); } } /** Clears tweet ratings and updates the relevant UI parts. */ function clearTweetRatingsAndRefreshUI() { if (isMobileDevice() || confirm('Are you sure you want to clear all cached tweet ratings?')) { // Clear tweet ratings cache Object.keys(tweetIDRatingCache).forEach(key => delete tweetIDRatingCache[key]); GM_setValue('tweetRatings', '{}'); // Clear thread relationships cache if (window.threadRelationships) { window.threadRelationships = {}; GM_setValue('threadRelationships', '{}'); } showStatus('All cached ratings and thread relationships cleared!'); updateCacheStatsUI(); // Re-process visible tweets if (observedTargetNode) { observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR).forEach(tweet => { tweet.dataset.sloppinessScore = ''; // Clear potential old score attribute delete tweet.dataset.cachedRating; delete tweet.dataset.blacklisted; processedTweets.delete(getTweetID(tweet)); scheduleTweetProcessing(tweet); }); } // Reset thread mapping on any conversation containers document.querySelectorAll('div[aria-label="Timeline: Conversation"], div[aria-label^="Timeline: Conversation"]').forEach(conversation => { delete conversation.dataset.threadMapping; delete conversation.dataset.threadMappedAt; delete conversation.dataset.threadMappingInProgress; delete conversation.dataset.threadHist; delete conversation.dataset.threadMediaUrls; }); } } /** Saves the custom instructions from the textarea. */ function saveInstructions() { const instructionsTextarea = document.getElementById('user-instructions'); USER_DEFINED_INSTRUCTIONS = instructionsTextarea.value; GM_setValue('userDefinedInstructions', USER_DEFINED_INSTRUCTIONS); showStatus('Scoring instructions saved! New tweets will use these instructions.'); if (isMobileDevice() || confirm('Do you want to clear the rating cache to apply these instructions to all tweets?')) { clearTweetRatingsAndRefreshUI(); } } /** Adds a handle from the input field to the blacklist. */ function addHandleFromInput() { const handleInput = document.getElementById('handle-input'); const handle = handleInput.value.trim(); if (handle) { addHandleToBlacklist(handle); handleInput.value = ''; // Clear input after adding } } /** * Handles changes to general setting inputs/toggles. * @param {HTMLElement} target - The input/toggle element that changed. * @param {string} settingName - The name of the setting (from data-setting). */ function handleSettingChange(target, settingName) { let value; if (target.type === 'checkbox') { value = target.checked; } else { value = target.value; } // Update global variable if it exists if (window[settingName] !== undefined) { window[settingName] = value; } // Save to GM storage GM_setValue(settingName, value); // Special UI updates for specific settings if (settingName === 'enableImageDescriptions') { const imageModelContainer = document.getElementById('image-model-container'); if (imageModelContainer) { imageModelContainer.style.display = value ? 'block' : 'none'; } showStatus('Image descriptions ' + (value ? 'enabled' : 'disabled')); } } /** * Handles changes to parameter control sliders/number inputs. * @param {HTMLElement} target - The slider or number input element. * @param {string} paramName - The name of the parameter (from data-param-name). */ function handleParameterChange(target, paramName) { const row = target.closest('.parameter-row'); if (!row) return; const slider = row.querySelector('.parameter-slider'); const valueInput = row.querySelector('.parameter-value'); const min = parseFloat(slider.min); const max = parseFloat(slider.max); let newValue = parseFloat(target.value); // Clamp value if it's from the number input if (target.type === 'number' && !isNaN(newValue)) { newValue = Math.max(min, Math.min(max, newValue)); } // Update both slider and input if (slider && valueInput) { slider.value = newValue; valueInput.value = newValue; } // Update global variable if (window[paramName] !== undefined) { window[paramName] = newValue; } // Save to GM storage GM_setValue(paramName, newValue); } /** * Handles changes to the main filter slider. * @param {HTMLElement} slider - The filter slider element. */ function handleFilterSliderChange(slider) { const valueDisplay = document.getElementById('tweet-filter-value'); currentFilterThreshold = parseInt(slider.value, 10); if (valueDisplay) { valueDisplay.textContent = currentFilterThreshold.toString(); } GM_setValue('filterThreshold', currentFilterThreshold); applyFilteringToAll(); } /** * Switches the active tab in the settings panel. * @param {string} tabName - The name of the tab to activate (from data-tab). */ function switchTab(tabName) { const settingsContent = document.querySelector('#settings-container .settings-content'); if (!settingsContent) return; const tabs = settingsContent.querySelectorAll('.tab-content'); const buttons = settingsContent.querySelectorAll('.tab-navigation .tab-button'); tabs.forEach(tab => tab.classList.remove('active')); buttons.forEach(btn => btn.classList.remove('active')); const tabToShow = settingsContent.querySelector(`#${tabName}-tab`); const buttonToActivate = settingsContent.querySelector(`.tab-navigation .tab-button[data-tab="${tabName}"]`); if (tabToShow) tabToShow.classList.add('active'); if (buttonToActivate) buttonToActivate.classList.add('active'); } /** * Toggles the visibility of advanced options sections. * @param {string} contentId - The ID of the content element to toggle. */ function toggleAdvancedOptions(contentId) { const content = document.getElementById(contentId); const toggle = document.querySelector(`[data-toggle="${contentId}"]`); if (!content || !toggle) return; const icon = toggle.querySelector('.advanced-toggle-icon'); const isExpanded = content.classList.toggle('expanded'); if (icon) { icon.classList.toggle('expanded', isExpanded); } // Adjust max-height for smooth animation if (isExpanded) { content.style.maxHeight = content.scrollHeight + 'px'; } else { content.style.maxHeight = '0'; } } // --- UI Update Functions --- /** Updates the cache statistics display in the General tab. */ function updateCacheStatsUI() { const cachedCountEl = document.getElementById('cached-ratings-count'); const whitelistedCountEl = document.getElementById('whitelisted-handles-count'); if (cachedCountEl) { cachedCountEl.textContent = Object.keys(tweetIDRatingCache).length; } if (whitelistedCountEl) { whitelistedCountEl.textContent = blacklistedHandles.length; } } /** * Refreshes the entire settings UI to reflect current settings. */ function refreshSettingsUI() { // Update general settings inputs/toggles document.querySelectorAll('[data-setting]').forEach(input => { const settingName = input.dataset.setting; const value = GM_getValue(settingName, window[settingName]); // Get saved or default value if (input.type === 'checkbox') { input.checked = value; // Trigger change handler for side effects (like hiding/showing image model section) handleSettingChange(input, settingName); } else { input.value = value; } }); // Update parameter controls (sliders/number inputs) document.querySelectorAll('.parameter-row[data-param-name]').forEach(row => { const paramName = row.dataset.paramName; const slider = row.querySelector('.parameter-slider'); const valueInput = row.querySelector('.parameter-value'); const value = GM_getValue(paramName, window[paramName]); if (slider) slider.value = value; if (valueInput) valueInput.value = value; }); // Update filter slider const filterSlider = document.getElementById('tweet-filter-slider'); const filterValueDisplay = document.getElementById('tweet-filter-value'); if (filterSlider && filterValueDisplay) { filterSlider.value = currentFilterThreshold.toString(); filterValueDisplay.textContent = currentFilterThreshold.toString(); } // Refresh dynamically populated lists/dropdowns refreshHandleList(document.getElementById('handle-list')); refreshModelsUI(); // Refreshes model dropdowns // Update cache stats updateCacheStatsUI(); // Set initial state for advanced sections (collapsed by default unless CSS specifies otherwise) document.querySelectorAll('.advanced-content').forEach(content => { if (!content.classList.contains('expanded')) { content.style.maxHeight = '0'; } }); document.querySelectorAll('.advanced-toggle-icon.expanded').forEach(icon => { // Ensure icon matches state if CSS defaults to expanded if (!icon.closest('.advanced-toggle')?.nextElementSibling?.classList.contains('expanded')) { icon.classList.remove('expanded'); } }); } /** * Refreshes the handle list UI. * @param {HTMLElement} listElement - The list element to refresh. */ function refreshHandleList(listElement) { if (!listElement) return; listElement.innerHTML = ''; // Clear existing list if (blacklistedHandles.length === 0) { const emptyMsg = document.createElement('div'); emptyMsg.style.cssText = 'padding: 8px; opacity: 0.7; font-style: italic;'; emptyMsg.textContent = 'No handles added yet'; listElement.appendChild(emptyMsg); return; } blacklistedHandles.forEach(handle => { const item = document.createElement('div'); item.className = 'handle-item'; const handleText = document.createElement('div'); handleText.className = 'handle-text'; handleText.textContent = '@' + handle; item.appendChild(handleText); const removeBtn = document.createElement('button'); removeBtn.className = 'remove-handle'; removeBtn.textContent = '×'; removeBtn.title = 'Remove from list'; // removeBtn listener is handled by delegation in initializeEventListeners item.appendChild(removeBtn); listElement.appendChild(item); }); } /** * Updates the model selection dropdowns based on availableModels. */ function refreshModelsUI() { const modelSelectContainer = document.getElementById('model-select-container'); const imageModelSelectContainer = document.getElementById('image-model-select-container'); const models = availableModels || []; // Ensure availableModels is an array // Update main model selector if (modelSelectContainer) { modelSelectContainer.innerHTML = ''; // Clear current createCustomSelect( modelSelectContainer, 'model-selector', // ID for the custom select element models.map(model => ({ value: model.slug || model.id, label: formatModelLabel(model) })), selectedModel, // Current selected value (newValue) => { // onChange callback selectedModel = newValue; GM_setValue('selectedModel', selectedModel); showStatus('Rating model updated'); }, 'Search rating models...' // Placeholder ); } // Update image model selector if (imageModelSelectContainer) { const visionModels = models.filter(model => model.input_modalities?.includes('image') || model.architecture?.input_modalities?.includes('image') || model.architecture?.modality?.includes('image') ); imageModelSelectContainer.innerHTML = ''; // Clear current createCustomSelect( imageModelSelectContainer, 'image-model-selector', // ID for the custom select element visionModels.map(model => ({ value: model.slug || model.id, label: formatModelLabel(model) })), selectedImageModel, // Current selected value (newValue) => { // onChange callback selectedImageModel = newValue; GM_setValue('selectedImageModel', selectedImageModel); showStatus('Image model updated'); }, 'Search vision models...' // Placeholder ); } } /** * Formats a model object into a string for display in dropdowns. * @param {Object} model - The model object from the API. * @returns {string} A formatted label string. */ function formatModelLabel(model) { let label = model.slug || model.id || model.name || 'Unknown Model'; let pricingInfo = ''; // Extract pricing const pricing = model.endpoint?.pricing || model.pricing; if (pricing) { const promptPrice = parseFloat(pricing.prompt); const completionPrice = parseFloat(pricing.completion); if (!isNaN(promptPrice)) { pricingInfo += ` - $${promptPrice.toFixed(7)}/in`; if (!isNaN(completionPrice) && completionPrice !== promptPrice) { pricingInfo += ` $${completionPrice.toFixed(7)}/out`; } } else if (!isNaN(completionPrice)) { // Handle case where only completion price is available (less common) pricingInfo += ` - $${completionPrice.toFixed(7)}/out`; } } // Add vision icon const isVision = model.input_modalities?.includes('image') || model.architecture?.input_modalities?.includes('image') || model.architecture?.modality?.includes('image'); if (isVision) { label = '🖼️ ' + label; } return label + pricingInfo; } // --- Custom Select Dropdown Logic (largely unchanged, but included for completeness) --- /** * Creates a custom select dropdown with search functionality. * @param {HTMLElement} container - Container to append the custom select to. * @param {string} id - ID for the root custom-select div. * @param {Array<{value: string, label: string}>} options - Options for the dropdown. * @param {string} initialSelectedValue - Initially selected value. * @param {Function} onChange - Callback function when selection changes. * @param {string} searchPlaceholder - Placeholder text for the search input. */ function createCustomSelect(container, id, options, initialSelectedValue, onChange, searchPlaceholder) { let currentSelectedValue = initialSelectedValue; const customSelect = document.createElement('div'); customSelect.className = 'custom-select'; customSelect.id = id; const selectSelected = document.createElement('div'); selectSelected.className = 'select-selected'; const selectItems = document.createElement('div'); selectItems.className = 'select-items'; selectItems.style.display = 'none'; // Initially hidden const searchField = document.createElement('div'); searchField.className = 'search-field'; const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.className = 'search-input'; searchInput.placeholder = searchPlaceholder || 'Search...'; searchField.appendChild(searchInput); selectItems.appendChild(searchField); // Function to render options function renderOptions(filter = '') { // Clear previous options (excluding search field) while (selectItems.childNodes.length > 1) { selectItems.removeChild(selectItems.lastChild); } const filteredOptions = options.filter(opt => opt.label.toLowerCase().includes(filter.toLowerCase()) ); if (filteredOptions.length === 0) { const noResults = document.createElement('div'); noResults.textContent = 'No matches found'; noResults.style.cssText = 'opacity: 0.7; font-style: italic; padding: 10px; text-align: center; cursor: default;'; selectItems.appendChild(noResults); } filteredOptions.forEach(option => { const optionDiv = document.createElement('div'); optionDiv.textContent = option.label; optionDiv.dataset.value = option.value; if (option.value === currentSelectedValue) { optionDiv.classList.add('same-as-selected'); } optionDiv.addEventListener('click', (e) => { e.stopPropagation(); // Prevent closing immediately currentSelectedValue = option.value; selectSelected.textContent = option.label; selectItems.style.display = 'none'; selectSelected.classList.remove('select-arrow-active'); // Update classes for all items selectItems.querySelectorAll('div[data-value]').forEach(div => { div.classList.toggle('same-as-selected', div.dataset.value === currentSelectedValue); }); onChange(currentSelectedValue); }); selectItems.appendChild(optionDiv); }); } // Set initial display text const initialOption = options.find(opt => opt.value === currentSelectedValue); selectSelected.textContent = initialOption ? initialOption.label : 'Select an option'; customSelect.appendChild(selectSelected); customSelect.appendChild(selectItems); container.appendChild(customSelect); // Initial rendering renderOptions(); // Event listeners searchInput.addEventListener('input', () => renderOptions(searchInput.value)); searchInput.addEventListener('click', e => e.stopPropagation()); // Prevent closing selectSelected.addEventListener('click', (e) => { e.stopPropagation(); closeAllSelectBoxes(customSelect); // Close others const isHidden = selectItems.style.display === 'none'; selectItems.style.display = isHidden ? 'block' : 'none'; selectSelected.classList.toggle('select-arrow-active', isHidden); if (isHidden) { searchInput.focus(); searchInput.select(); // Select text for easy replacement renderOptions(); // Re-render in case options changed } }); } /** Closes all custom select dropdowns except the one passed in. */ function closeAllSelectBoxes(exceptThisOne = null) { document.querySelectorAll('.custom-select').forEach(select => { if (select === exceptThisOne) return; const items = select.querySelector('.select-items'); const selected = select.querySelector('.select-selected'); if (items) items.style.display = 'none'; if (selected) selected.classList.remove('select-arrow-active'); }); } // --- Rating Indicator Logic (Simplified, assuming CSS handles most styling) --- /** * Updates or creates the rating indicator on a tweet article. * @param {Element} tweetArticle - The tweet article element. * @param {number|null} score - The numeric rating (null if pending/error). * @param {string} status - 'pending', 'rated', 'error', 'cached', 'blacklisted', 'streaming'. * @param {string} [description] - Optional description for hover tooltip. * @param {string} [reasoning] - Optional reasoning trace. */ function setScoreIndicator(tweetArticle, score, status, description = "", reasoning = "") { const tweetId = getTweetID(tweetArticle); let indicator = tweetArticle.querySelector('.score-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.className = 'score-indicator'; tweetArticle.style.position = 'relative'; // Ensure parent is positioned tweetArticle.appendChild(indicator); // Create a unique tooltip for this indicator const tooltip = document.createElement('div'); tooltip.className = 'score-description'; tooltip.style.display = 'none'; // Store the tweet ID in the tooltip's dataset for cleanup tooltip.dataset.tweetId = tweetId; // Create the fixed structure for the tooltip // Add tooltip controls row with pin and copy buttons const tooltipControls = document.createElement('div'); tooltipControls.className = 'tooltip-controls'; const pinButton = document.createElement('button'); pinButton.className = 'tooltip-pin-button'; pinButton.innerHTML = '📌'; pinButton.title = 'Pin tooltip (prevents auto-closing)'; pinButton.addEventListener('click', function(e) { e.stopPropagation(); const isPinned = tooltip.classList.toggle('pinned'); this.innerHTML = isPinned ? '📍' : '📌'; this.title = isPinned ? 'Unpin tooltip' : 'Pin tooltip (prevents auto-closing)'; }); const copyButton = document.createElement('button'); copyButton.className = 'tooltip-copy-button'; copyButton.innerHTML = '📋'; copyButton.title = 'Copy content to clipboard'; copyButton.addEventListener('click', function(e) { e.stopPropagation(); const description = tooltip.querySelector('.description-text'); const reasoning = tooltip.querySelector('.reasoning-text'); let textToCopy = description ? description.textContent : ''; if (reasoning && reasoning.textContent) { textToCopy += '\n\nReasoning:\n' + reasoning.textContent; } navigator.clipboard.writeText(textToCopy).then(() => { const originalText = this.innerHTML; this.innerHTML = '✓'; setTimeout(() => { this.innerHTML = originalText; }, 1500); }).catch(err => { }); }); tooltipControls.appendChild(pinButton); tooltipControls.appendChild(copyButton); tooltip.appendChild(tooltipControls); // Create the reasoning dropdown structure upfront const reasoningDropdown = document.createElement('div'); reasoningDropdown.className = 'reasoning-dropdown'; // Create the toggle button without inline event const reasoningToggle = document.createElement('div'); reasoningToggle.className = 'reasoning-toggle'; const arrow = document.createElement('span'); arrow.className = 'reasoning-arrow'; arrow.textContent = '▶'; reasoningToggle.appendChild(arrow); reasoningToggle.appendChild(document.createTextNode(' Show Reasoning Trace')); // Add event listener properly reasoningToggle.addEventListener('click', function(e) { e.stopPropagation(); // Prevent bubbling to tooltip click handler const dropdown = this.closest('.reasoning-dropdown'); dropdown.classList.toggle('expanded'); // Update arrow const arrowSpan = this.querySelector('.reasoning-arrow'); arrowSpan.textContent = dropdown.classList.contains('expanded') ? '▼' : '▶'; // Ensure content is visible when expanded const content = dropdown.querySelector('.reasoning-content'); if (dropdown.classList.contains('expanded')) { content.style.maxHeight = '300px'; content.style.padding = '10px'; } else { content.style.maxHeight = '0'; content.style.padding = '0'; } }); // Create reasoning content const reasoningContent = document.createElement('div'); reasoningContent.className = 'reasoning-content'; const reasoningText = document.createElement('p'); reasoningText.className = 'reasoning-text'; reasoningContent.appendChild(reasoningText); // Assemble the dropdown reasoningDropdown.appendChild(reasoningToggle); reasoningDropdown.appendChild(reasoningContent); tooltip.appendChild(reasoningDropdown); // Create a paragraph for the description const descriptionParagraph = document.createElement('p'); descriptionParagraph.className = 'description-text'; tooltip.appendChild(descriptionParagraph); // Add scroll-to-bottom button const scrollButton = document.createElement('div'); scrollButton.className = 'scroll-to-bottom-button'; scrollButton.innerHTML = '⬇ Scroll to bottom'; scrollButton.style.display = 'none'; // Hidden by default scrollButton.addEventListener('click', (e) => { e.stopPropagation(); // Initial scroll tooltip.scrollTop = tooltip.scrollHeight; // Second scroll after a short delay to catch any new content setTimeout(() => { tooltip.scrollTop = tooltip.scrollHeight; }, 150); tooltip.dataset.autoScroll = 'true'; scrollButton.style.display = 'none'; }); tooltip.appendChild(scrollButton); // Add some spacing at the bottom for better scrolling experience const bottomSpacer = document.createElement('div'); bottomSpacer.className = 'tooltip-bottom-spacer'; tooltip.appendChild(bottomSpacer); // Set initial auto-scroll state tooltip.dataset.autoScroll = 'true'; // Add scroll event to detect when user manually scrolls tooltip.addEventListener('scroll', () => { // Check if we're near the bottom const isNearBottom = tooltip.scrollHeight - tooltip.scrollTop - tooltip.clientHeight < 30; if (!isNearBottom && tooltip.dataset.autoScroll === 'true') { // User has scrolled up, disable auto-scroll tooltip.dataset.autoScroll = 'false'; // Show the scroll-to-bottom button if (tooltip.classList.contains('streaming-tooltip')) { const scrollButton = tooltip.querySelector('.scroll-to-bottom-button'); if (scrollButton && scrollButton.style.display === 'none') { scrollButton.style.display = 'block'; } } } else if (isNearBottom && tooltip.dataset.autoScroll === 'false') { // User has manually scrolled to bottom, re-enable auto-scroll tooltip.dataset.autoScroll = 'true'; // Hide the scroll-to-bottom button const scrollButton = tooltip.querySelector('.scroll-to-bottom-button'); if (scrollButton) { scrollButton.style.display = 'none'; } } }); document.body.appendChild(tooltip); // Append to body instead of indicator // Store the tooltip reference indicator.scoreTooltip = tooltip; // Add mouse hover listeners indicator.addEventListener('mouseenter', handleIndicatorMouseEnter); indicator.addEventListener('mouseleave', handleIndicatorMouseLeave); // Add click/tap handler for toggling tooltip indicator.addEventListener('click', function(e) { e.stopPropagation(); // Prevent opening the tweet e.preventDefault(); toggleTooltipVisibility(this); }); // Also add hover listeners to the tooltip tooltip.addEventListener('mouseenter', () => { if (!tooltip.classList.contains('pinned')) { tooltip.style.display = 'block'; } }); tooltip.addEventListener('mouseleave', () => { if (!tooltip.classList.contains('pinned')) { tooltip.style.display = 'none'; } }); // Add click handler to close tooltip when clicking outside tooltip.addEventListener('click', (e) => { // Only if not clicking reasoning toggle, buttons, or scroll button if (!e.target.closest('.reasoning-toggle') && !e.target.closest('.scroll-to-bottom-button') && !e.target.closest('.tooltip-controls')) { if (!tooltip.classList.contains('pinned')) { tooltip.style.display = 'none'; } } }); // Apply mobile positioning if needed if (isMobileDevice()) { indicator.classList.add('mobile-indicator'); } } // Update status class and text content indicator.classList.remove('pending-rating', 'rated-rating', 'error-rating', 'cached-rating', 'blacklisted-rating', 'streaming-rating'); // Clear previous indicator.dataset.description = description || ''; // Store description indicator.dataset.reasoning = reasoning || ''; // Store reasoning indicator.dataset.tweetId = tweetId; // Store tweet ID in indicator // Update the tooltip content const tooltip = indicator.scoreTooltip; if (tooltip) { tooltip.dataset.tweetId = tweetId; // Ensure the tooltip also has the tweet ID updateTooltipContent(tooltip, description, reasoning); } switch (status) { case 'pending': indicator.classList.add('pending-rating'); indicator.textContent = '⏳'; break; case 'streaming': indicator.classList.add('streaming-rating'); indicator.textContent = '🔄'; break; case 'error': indicator.classList.add('error-rating'); indicator.textContent = '⚠️'; break; case 'cached': indicator.classList.add('cached-rating'); indicator.textContent = score; break; case 'blacklisted': indicator.classList.add('blacklisted-rating'); indicator.textContent = score; // Typically 10 for blacklisted break; case 'rated': // Default/normal rated default: indicator.classList.add('rated-rating'); // Add a general rated class indicator.textContent = score; break; } } /** * Toggles the visibility of a tooltip associated with an indicator * @param {HTMLElement} indicator - The indicator element */ function toggleTooltipVisibility(indicator) { const tooltip = indicator.scoreTooltip; if (!tooltip) return; if (tooltip.style.display === 'block') { // Don't close if pinned if (!tooltip.classList.contains('pinned')) { tooltip.style.display = 'none'; } } else { positionTooltip(indicator, tooltip); tooltip.style.display = 'block'; } } /** * Positions the tooltip relative to the indicator * @param {HTMLElement} indicator - The indicator element * @param {HTMLElement} tooltip - The tooltip element */ function positionTooltip(indicator, tooltip) { if (!indicator || !tooltip) return; const rect = indicator.getBoundingClientRect(); const margin = 10; const isMobile = isMobileDevice(); const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; const safeArea = viewportHeight - margin; // Safe area to stay within // Reset any previous height constraints to measure true dimensions tooltip.style.maxHeight = ''; tooltip.style.overflowY = ''; // Force layout recalculation to get true dimensions tooltip.style.display = 'block'; tooltip.style.visibility = 'hidden'; const tooltipWidth = tooltip.offsetWidth; const tooltipHeight = tooltip.offsetHeight; let left, top; if (isMobile) { // Center tooltip horizontally on mobile left = Math.max(0, (viewportWidth - tooltipWidth) / 2); // Always apply a max-height on mobile to ensure scrollability const maxTooltipHeight = viewportHeight * 0.8; // 80% of viewport // If tooltip is taller than allowed, constrain it and enable scrolling if (tooltipHeight > maxTooltipHeight) { tooltip.style.maxHeight = `${maxTooltipHeight}px`; tooltip.style.overflowY = 'scroll'; } // Position at the bottom part of the screen top = (viewportHeight - tooltip.offsetHeight) / 2; // Ensure it's always fully visible if (top < margin) { top = margin; } if (top + tooltip.offsetHeight > safeArea) { top = safeArea - tooltip.offsetHeight; } } else { // Desktop positioning - to the right of indicator left = rect.right + margin; top = rect.top + (rect.height / 2) - (tooltipHeight / 2); // Check horizontal overflow if (left + tooltipWidth > viewportWidth - margin) { // Try positioning to the left of indicator left = rect.left - tooltipWidth - margin; // If that doesn't work either, center horizontally if (left < margin) { left = Math.max(margin, (viewportWidth - tooltipWidth) / 2); // And position below or above the indicator if (rect.bottom + tooltipHeight + margin <= safeArea) { top = rect.bottom + margin; } else if (rect.top - tooltipHeight - margin >= margin) { top = rect.top - tooltipHeight - margin; } else { // If doesn't fit above or below, center vertically top = margin; // Apply max height and scrolling tooltip.style.maxHeight = `${safeArea - (margin * 2)}px`; tooltip.style.overflowY = 'scroll'; } } } // Final vertical adjustment and scrolling if needed if (top < margin) { top = margin; } if (top + tooltipHeight > safeArea) { // If tooltip is too tall for the viewport, enable scrolling if (tooltipHeight > safeArea - margin) { top = margin; tooltip.style.maxHeight = `${safeArea - (margin * 2)}px`; tooltip.style.overflowY = 'scroll'; } else { // Otherwise just move it up top = safeArea - tooltipHeight; } } } // Apply the position tooltip.style.position = 'fixed'; tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; tooltip.style.zIndex = '99999999'; tooltip.style.visibility = 'visible'; // Force scrollbars on WebKit browsers if needed if (tooltip.style.overflowY === 'scroll') { tooltip.style.WebkitOverflowScrolling = 'touch'; } // Store the current scroll position to check if user has manually scrolled tooltip.lastScrollTop = tooltip.scrollTop; // Update scroll position for streaming tooltips if (tooltip.classList.contains('streaming-tooltip')) { const isAtBottom = tooltip.scrollHeight - tooltip.scrollTop - tooltip.clientHeight < 30; const isInitialDisplay = tooltip.lastDisplayTime === undefined || (Date.now() - tooltip.lastDisplayTime) > 1000; if (isInitialDisplay || isAtBottom) { setTimeout(() => { tooltip.scrollTop = tooltip.scrollHeight; }, 10); } } // Track when we displayed the tooltip tooltip.lastDisplayTime = Date.now(); } /** * Detects if the user is on a mobile device * @returns {boolean} true if mobile device detected */ function isMobileDevice() { return (window.innerWidth <= 600 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)); } /** * This function is no longer needed as each indicator has its own tooltip * It's kept for backward compatibility but is not used */ function getScoreTooltip() { return null; } /** Formats description text for the tooltip. */ function formatTooltipDescription(description, reasoning = "") { if (!description) return ''; // Add markdown-style formatting description = description.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); // Bold description = description.replace(/\*([^*]+)\*/g, '<em>$1</em>'); // Italic //h4 description = description.replace(/^#### (.*)$/gm, '<h4>$1</h4>'); //h3 description = description.replace(/^### (.*)$/gm, '<h3>$1</h3>'); //h2 description = description.replace(/^## (.*)$/gm, '<h2>$1</h2>'); //h1 description = description.replace(/^# (.*)$/gm, '<h1>$1</h1>'); // Basic formatting, can be expanded description = description.replace(/SCORE_(\d+)/g, '<span style="display:inline-block;background-color:#1d9bf0;color:white;padding:3px 10px;border-radius:9999px;margin:8px 0;font-weight:bold;">SCORE: $1</span>'); description = description.replace(/\n\n/g, '<br><br>'); // Keep in single paragraph description = description.replace(/\n/g, '<br>'); // Format reasoning trace with markdown support if provided let formattedReasoning = ''; if (reasoning && reasoning.trim()) { formattedReasoning = reasoning .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') // Bold .replace(/\*([^*]+)\*/g, '<em>$1</em>') // Italic .replace(/\n\n/g, '<br><br>') // Keep in single paragraph .replace(/\n/g, '<br>'); } return { description: description, reasoning: formattedReasoning }; } /** * Updates tooltip content during streaming * @param {HTMLElement} tooltip - The tooltip element to update * @param {string} description - Current description content * @param {string} reasoning - Current reasoning content */ function updateTooltipContent(tooltip, description, reasoning) { if (!tooltip) return; const formatted = formatTooltipDescription(description, reasoning); // Update only the contents, not the structure const descriptionElement = tooltip.querySelector('.description-text'); if (descriptionElement) { descriptionElement.innerHTML = formatted.description; } const reasoningElement = tooltip.querySelector('.reasoning-text'); if (reasoningElement && formatted.reasoning) { reasoningElement.innerHTML = formatted.reasoning; // Make reasoning dropdown visible only if there is content const dropdown = tooltip.querySelector('.reasoning-dropdown'); if (dropdown) { dropdown.style.display = formatted.reasoning ? 'block' : 'none'; } } // Auto-scroll behavior for streaming updates if (tooltip.style.display === 'block') { // Check if auto-scroll is enabled for this tooltip if (tooltip.dataset.autoScroll === 'true') { // Use requestAnimationFrame to ensure we're scrolling after content is rendered requestAnimationFrame(() => { tooltip.scrollTop = tooltip.scrollHeight; // Second scroll after a short delay to catch any new content setTimeout(() => { tooltip.scrollTop = tooltip.scrollHeight; }, 150); }); } else { // Show the scroll-to-bottom button if we're in a streaming tooltip if (tooltip.classList.contains('streaming-tooltip')) { const scrollButton = tooltip.querySelector('.scroll-to-bottom-button'); if (scrollButton && scrollButton.style.display === 'none') { scrollButton.style.display = 'block'; } } } } } /** Handles mouse enter event for score indicators. */ function handleIndicatorMouseEnter(event) { // Only use hover behavior on non-mobile if (isMobileDevice()) return; const indicator = event.currentTarget; const tooltip = indicator.scoreTooltip; if (!tooltip) return; // Get the tweet article const tweetArticle = indicator.closest('article[data-testid="tweet"]'); const tweetId = tweetArticle ? getTweetID(tweetArticle) : null; // Position the tooltip positionTooltip(indicator, tooltip); tooltip.style.display = 'block'; // Check if we have cached streaming content for this tweet if (tweetId && tweetIDRatingCache[tweetId]?.description) { const reasoning = tweetIDRatingCache[tweetId].reasoning || ""; const formatted = formatTooltipDescription(tweetIDRatingCache[tweetId].description, reasoning); // Update content using the proper elements const descriptionElement = tooltip.querySelector('.description-text'); if (descriptionElement) { descriptionElement.innerHTML = formatted.description; } const reasoningElement = tooltip.querySelector('.reasoning-text'); if (reasoningElement) { reasoningElement.innerHTML = formatted.reasoning; // Show/hide reasoning dropdown based on content const dropdown = tooltip.querySelector('.reasoning-dropdown'); if (dropdown) { dropdown.style.display = formatted.reasoning ? 'block' : 'none'; } } // Add streaming class if status is streaming if (tweetArticle?.dataset.ratingStatus === 'streaming' || tweetIDRatingCache[tweetId].streaming === true) { tooltip.classList.add('streaming-tooltip'); // Reset auto-scroll state for streaming tooltips when they're first shown tooltip.dataset.autoScroll = 'true'; // Scroll to bottom immediately for streaming tooltips requestAnimationFrame(() => { tooltip.scrollTop = tooltip.scrollHeight; // Second scroll after a short delay to catch any new content setTimeout(() => { tooltip.scrollTop = tooltip.scrollHeight; }, 150); }); // Hide scroll button initially const scrollButton = tooltip.querySelector('.scroll-to-bottom-button'); if (scrollButton) { scrollButton.style.display = 'none'; } } else { tooltip.classList.remove('streaming-tooltip'); } } } /** Handles mouse leave event for score indicators. */ function handleIndicatorMouseLeave(event) { // Only use hover behavior on non-mobile if (isMobileDevice()) return; const indicator = event.currentTarget; const tooltip = indicator.scoreTooltip; if (!tooltip) return; // Only hide if we're not moving to the tooltip itself and not pinned setTimeout(() => { if (tooltip && !tooltip.matches(':hover') && !tooltip.classList.contains('pinned')) { tooltip.style.display = 'none'; } }, 100); } /** Cleans up the global score tooltip element. */ function cleanupDescriptionElements() { // Remove all tooltips that might be in the DOM document.querySelectorAll('.score-description').forEach(tooltip => { tooltip.remove(); }); } /** * Cleans up orphaned tooltips that no longer have a visible tweet or indicator. */ function cleanupOrphanedTooltips() { // Get all tooltips const tooltips = document.querySelectorAll('.score-description'); tooltips.forEach(tooltip => { const tooltipTweetId = tooltip.dataset.tweetId; if (!tooltipTweetId) { // Remove tooltips without a tweet ID tooltip.remove(); return; } // Find the corresponding indicator for this tooltip const indicator = document.querySelector(`.score-indicator[data-tweet-id="${tooltipTweetId}"]`) || document.querySelector(`article[data-testid="tweet"][data-tweet-id="${tooltipTweetId}"] .score-indicator`); // Only remove the tooltip if there's no indicator for it if (!indicator) { // Cancel any active streaming requests for this tweet if (window.activeStreamingRequests && window.activeStreamingRequests[tooltipTweetId]) { window.activeStreamingRequests[tooltipTweetId].abort(); delete window.activeStreamingRequests[tooltipTweetId]; } // Remove the tooltip tooltip.remove(); } }); } // Add a MutationObserver to watch for removed tweets function initializeTooltipCleanup() { // Create a MutationObserver to watch for removed tweets const tweetObserver = new MutationObserver((mutations) => { let needsCleanup = false; mutations.forEach(mutation => { // Check for removed nodes that might be tweets or indicators mutation.removedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if the removed node is a tweet or contains indicators if (node.matches('.score-indicator') || node.querySelector('.score-indicator')) { needsCleanup = true; } } }); }); // Only run cleanup if we detected relevant DOM changes if (needsCleanup) { cleanupOrphanedTooltips(); } }); // Start observing the timeline with the configured parameters const observerConfig = { childList: true, subtree: true }; // Find the main timeline container const timeline = document.querySelector('div[data-testid="primaryColumn"]'); if (timeline) { tweetObserver.observe(timeline, observerConfig); } // Also keep the periodic cleanup as a backup, but with a longer interval setInterval(cleanupOrphanedTooltips, 10000); } // --- Settings Import/Export (Simplified) --- /** * Exports all settings and cache to a JSON file. */ function exportSettings() { try { const settingsToExport = { apiKey: GM_getValue('openrouter-api-key', ''), selectedModel: GM_getValue('selectedModel', 'openai/gpt-4.1-nano'), selectedImageModel: GM_getValue('selectedImageModel', 'openai/gpt-4.1-nano'), enableImageDescriptions: GM_getValue('enableImageDescriptions', false), enableStreaming: GM_getValue('enableStreaming', true), modelTemperature: GM_getValue('modelTemperature', 0.5), modelTopP: GM_getValue('modelTopP', 0.9), imageModelTemperature: GM_getValue('imageModelTemperature', 0.5), imageModelTopP: GM_getValue('imageModelTopP', 0.9), maxTokens: GM_getValue('maxTokens', 0), filterThreshold: GM_getValue('filterThreshold', 1), userDefinedInstructions: GM_getValue('userDefinedInstructions', 'Rate the tweet on a scale from 1 to 10 based on its clarity, insight, creativity, and overall quality.'), modelSortOrder: GM_getValue('modelSortOrder', 'throughput-high-to-low') }; const data = { version: VERSION, date: new Date().toISOString(), settings: settingsToExport, blacklistedHandles: blacklistedHandles || [], tweetRatings: tweetIDRatingCache || {} }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `tweetfilter-ai-backup-${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); showStatus('Settings exported successfully!'); } catch (error) { showStatus('Error exporting settings: ' + error.message); } } /** * Imports settings and cache from a JSON file. */ function importSettings() { try { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const data = JSON.parse(event.target.result); if (!data.settings) throw new Error('Invalid backup file format'); // Import settings for (const key in data.settings) { if (window[key] !== undefined) { window[key] = data.settings[key]; } GM_setValue(key, data.settings[key]); } // Import blacklisted handles if (data.blacklistedHandles && Array.isArray(data.blacklistedHandles)) { blacklistedHandles = data.blacklistedHandles; GM_setValue('blacklistedHandles', blacklistedHandles.join('\n')); } // Import tweet ratings (merge with existing) if (data.tweetRatings && typeof data.tweetRatings === 'object') { Object.assign(tweetIDRatingCache, data.tweetRatings); saveTweetRatings(); } refreshSettingsUI(); fetchAvailableModels(); showStatus('Settings imported successfully!'); } catch (error) { showStatus('Error importing settings: ' + error.message); } }; reader.readAsText(file); }; input.click(); } catch (error) { showStatus('Error importing settings: ' + error.message); } } /** * Resets all configurable settings to their default values. */ function resetSettings(noconfirm=false) { if (noconfirm || confirm('Are you sure you want to reset all settings to their default values? This will not clear your cached ratings or blacklisted handles.')) { // Define defaults (should match config.js ideally) const defaults = { selectedModel: 'openai/gpt-4.1-nano', selectedImageModel: 'openai/gpt-4.1-nano', enableImageDescriptions: false, enableStreaming: true, modelTemperature: 0.5, modelTopP: 0.9, imageModelTemperature: 0.5, imageModelTopP: 0.9, maxTokens: 0, filterThreshold: 5, userDefinedInstructions: 'Rate the tweet on a scale from 1 to 10 based on its clarity, insight, creativity, and overall quality.', modelSortOrder: 'throughput-high-to-low' }; // Apply defaults for (const key in defaults) { if (window[key] !== undefined) { window[key] = defaults[key]; } GM_setValue(key, defaults[key]); } refreshSettingsUI(); fetchAvailableModels(); showStatus('Settings reset to defaults'); } } // --- Blacklist/Whitelist Logic --- /** * Adds a handle to the blacklist, saves, and refreshes the UI. * @param {string} handle - The Twitter handle to add (with or without @). */ function addHandleToBlacklist(handle) { handle = handle.trim().replace(/^@/, ''); // Clean handle if (handle === '' || blacklistedHandles.includes(handle)) { showStatus(handle === '' ? 'Handle cannot be empty.' : `@${handle} is already on the list.`); return; } blacklistedHandles.push(handle); GM_setValue('blacklistedHandles', blacklistedHandles.join('\n')); refreshHandleList(document.getElementById('handle-list')); updateCacheStatsUI(); showStatus(`Added @${handle} to auto-rate list.`); } /** * Removes a handle from the blacklist, saves, and refreshes the UI. * @param {string} handle - The Twitter handle to remove (without @). */ function removeHandleFromBlacklist(handle) { const index = blacklistedHandles.indexOf(handle); if (index > -1) { blacklistedHandles.splice(index, 1); GM_setValue('blacklistedHandles', blacklistedHandles.join('\n')); refreshHandleList(document.getElementById('handle-list')); updateCacheStatsUI(); showStatus(`Removed @${handle} from auto-rate list.`); } else { } } // --- Initialization --- /** * Main initialization function for the UI module. */ function initialiseUI() { const uiContainer = injectUI(); if (!uiContainer) return; initializeEventListeners(uiContainer); refreshSettingsUI(); fetchAvailableModels(); // Initialize the floating cache stats badge updateFloatingCacheStats(); // Set up a periodic refresh of the cache stats to catch any updates setInterval(updateFloatingCacheStats, 10000); // Initialize the tooltip cleanup system initializeTooltipCleanup(); // Initialize tracking object for streaming requests if it doesn't exist if (!window.activeStreamingRequests) { window.activeStreamingRequests = {}; } } /** * Creates or updates a floating badge showing the current cache statistics * This provides real-time feedback when tweets are rated and cached, * even when the settings panel is not open. */ function updateFloatingCacheStats() { let statsBadge = document.getElementById('tweet-filter-stats-badge'); if (!statsBadge) { statsBadge = document.createElement('div'); statsBadge.id = 'tweet-filter-stats-badge'; statsBadge.className = 'tweet-filter-stats-badge'; statsBadge.style.cssText = ` position: fixed; bottom: 50px; right: 20px; background-color: rgba(29, 155, 240, 0.9); color: white; padding: 5px 10px; border-radius: 15px; font-size: 12px; z-index: 9999; box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: opacity 0.3s; cursor: pointer; display: flex; align-items: center; `; // Add tooltip functionality statsBadge.title = 'Click to open settings'; // Add click event to open settings statsBadge.addEventListener('click', () => { const settingsToggle = document.querySelector('.settings-toggle'); if (settingsToggle) { settingsToggle.click(); } }); document.body.appendChild(statsBadge); // Auto-hide after 5 seconds of inactivity let fadeTimeout; const resetFadeTimeout = () => { clearTimeout(fadeTimeout); statsBadge.style.opacity = '1'; fadeTimeout = setTimeout(() => { statsBadge.style.opacity = '0.3'; }, 5000); }; statsBadge.addEventListener('mouseenter', () => { statsBadge.style.opacity = '1'; clearTimeout(fadeTimeout); }); statsBadge.addEventListener('mouseleave', resetFadeTimeout); resetFadeTimeout(); } // Update the content const cachedCount = Object.keys(tweetIDRatingCache).length; const wlCount = blacklistedHandles.length; statsBadge.innerHTML = ` <span style="margin-right: 5px;">🧠</span> <span>${cachedCount} rated</span> ${wlCount > 0 ? `<span style="margin-left: 5px;"> | ${wlCount} whitelisted</span>` : ''} `; // Make it visible and reset the timeout statsBadge.style.opacity = '1'; clearTimeout(statsBadge.fadeTimeout); statsBadge.fadeTimeout = setTimeout(() => { statsBadge.style.opacity = '0.3'; }, 5000); } // Extend the updateCacheStatsUI function to also update the floating stats badge const originalUpdateCacheStatsUI = updateCacheStatsUI; updateCacheStatsUI = function() { // Call the original function originalUpdateCacheStatsUI.apply(this, arguments); // Update the floating badge updateFloatingCacheStats(); }; })();