您需要先安装一个扩展,例如 篡改猴、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
- // @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';
- console.log("X/Twitter Tweet De-Sloppification Activated (Combined Version)");
- // 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%;
- }
- .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: absolute;
- width: clamp(300px, 30vw, 500px);
- max-height: 60vh;
- overflow-y: auto;
- border: 1px solid rgba(255, 255, 255, 0.1);
- word-wrap: break-word;
- }
- /* Rating status classes */
- .cached-rating {
- background-color: rgba(76, 175, 80, 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;
- }
- .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;
- }
- </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="1" 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="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="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>
- </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="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="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: absolute;
- width: clamp(300px, 30vw, 500px);
- max-height: 60vh;
- overflow-y: auto;
- border: 1px solid rgba(255, 255, 255, 0.1);
- word-wrap: break-word;
- }
- /* Rating status classes */
- .cached-rating {
- background-color: rgba(76, 175, 80, 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;
- }
- .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 -----
- (function () {
- 'use strict';
- console.log("X/Twitter Tweet De-Sloppification Activated (v1.3 - Enhanced)");
- // 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;
- console.log("X/Twitter Tweet De-Sloppification: Target node found. Observing...");
- initialiseUI();
- if (firstRun){
- resetSettings(true);
- GM_setValue('firstRun', false);
- }
- // If no API key is found, prompt the user
- const apiKey = GM_getValue('openrouter-api-key', '');
- if (!apiKey) {
- apiKey = prompt("<TweetFilter AI>\nPlease enter your OpenRouter API key. You can get one at https://openrouter.ai/");
- if (apiKey) {
- GM_setValue('openrouter-api-key', apiKey);
- }
- showStatus("No API key found. Please enter your OpenRouter API key.");
- } else {
- 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);
- const observer = new MutationObserver(handleMutations);
- observer.observe(observedTargetNode, { childList: true, subtree: true });
- // Periodically ensure all tweets have been processed
- setInterval(ensureAllTweetsRated, 3000);
- 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();
- // Clean up all description elements
- cleanupDescriptionElements();
- console.log("X/Twitter Tweet De-Sloppification Deactivated.");
- });
- } 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
- const tweetIDRatingCache = {}; // ID-based cache for persistent storage
- const PROCESSING_DELAY_MS = 500; // Delay before processing a tweet (ms)
- const API_CALL_DELAY_MS = 250; // 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', 'google/gemini-flash-1.5-8b');
- let selectedImageModel = GM_getValue('selectedImageModel', 'google/gemini-flash-1.5-8b');
- let blacklistedHandles = GM_getValue('blacklistedHandles', '').split('\n').filter(h => h.trim() !== '');
- let storedRatings = GM_getValue('tweetRatings', '{}');
- // Settings variables
- let enableImageDescriptions = GM_getValue('enableImageDescriptions', false);
- // Model parameters
- 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');
- }
- try {
- Object.assign(tweetIDRatingCache, JSON.parse(storedRatings));
- console.log(`Loaded ${Object.keys(tweetIDRatingCache).length} cached tweet ratings`);
- } catch (e) {
- console.error('Error loading stored ratings:', 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/532182-twitter-x-ai-tweet-filter",
- "X-Title": "Tweet Rating Tool"
- },
- data: JSON.stringify(request),
- timeout: timeout,
- onload: function (response) {
- if (response.status >= 200 && response.status < 300) {
- try {
- const data = JSON.parse(response.responseText);
- 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
- });
- }
- });
- });
- }
- 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}>} The rating result
- */
- async function rateTweetWithOpenRouter(tweetText, tweetId, apiKey, mediaUrls, maxRetries = 3) {
- const request = {
- model: selectedModel,
- messages: [{
- role: "user",
- content: [{
- type: "text",
- text: `
- 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 an expert critic of tweets. You are to review and provide a rating for the tweet with tweet ID ${tweetId}.
- Ensure that you consider these user-defined instructions in your analysis and scoring:
- [USER-DEFINED INSTRUCTIONS]:
- ${USER_DEFINED_INSTRUCTIONS}
- Provide a concise explanation of your reasoning and then, on a new line, output your final rating in the exact format:
- SCORE_X where X is a number from 1 (lowest quality) to 10 (highest quality).
- for example: 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.
- _______BEGIN TWEET_______
- ${tweetText}
- _______END TWEET_______`
- }]
- }],
- temperature: modelTemperature,
- top_p: modelTopP,
- max_tokens: maxTokens,
- provider: {
- sort: GM_getValue('modelSortOrder', 'throughput-high-to-low').split('-')[0],
- allow_fallbacks: true
- }
- };
- 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[0].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
- };
- // Implement retry logic with exponential backoff
- 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)`);
- // Make API request
- const result = await getCompletion(request, apiKey);
- pendingRequests--;
- showStatus(`Rating tweet... (${pendingRequests} pending)`);
- if (!result.error && result.data?.choices?.[0]?.message?.content) {
- const content = result.data.choices[0].message.content;
- const scoreMatch = content.match(/\SCORE_(\d+)/);
- if (scoreMatch) {
- const score = parseInt(scoreMatch[1], 10);
- tweetIDRatingCache[tweetId] = {
- tweetContent: tweetText,
- score: score,
- description: content
- };
- saveTweetRatings();
- return { score, content, error: false };
- }
- }
- // Handle retries
- if (attempt < maxRetries) {
- const backoffDelay = Math.pow(attempt, 2) * 1000;
- console.log(`Attempt ${attempt}/${maxRetries} failed. Retrying in ${backoffDelay}ms...`);
- console.log('Response:', {
- error: result.error,
- message: result.message,
- data: result.data,
- content: result.data?.choices?.[0]?.message?.content
- });
- await new Promise(resolve => setTimeout(resolve, backoffDelay));
- }
- }
- return {
- score: 5,
- content: "Failed to get valid rating after multiple attempts",
- error: true
- };
- }
- /**
- * 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) {
- console.log('No API key available, skipping model fetch');
- 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) {
- console.error('Error parsing model list:', error);
- showStatus('Error parsing models list');
- }
- },
- onerror: function (error) {
- console.error('Error fetching models:', 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) {
- const handleElement = tweetArticle.querySelectorAll(USER_HANDLE_SELECTOR);
- let handles=[];
- if (handleElement) {
- /*
- const href = handleElement.getAttribute('href');
- if (href && href.startsWith('/')) {
- return href.slice(1);
- }
- */
- handleElement.forEach(element => {
- const href = element.getAttribute('href');
- if (href && href.startsWith('/')) {
- handles.push(href.slice(1));
- }
- });
- }
- return handles.length>0?handles:[''];
- }
- /**
- * Extracts and returns an array of media URLs from the tweet element.
- * @param {Element} scopeElement - The tweet element.
- * @param {string} tweetIdForDebug - The tweet ID (for logging).
- * @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
- scopeElement.querySelectorAll(`${MEDIA_IMG_SELECTOR}, ${MEDIA_VIDEO_SELECTOR}`).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
- /*
- if (!sourceUrl ||
- !(sourceUrl.includes('pbs.twimg.com/') ||
- sourceUrl.includes('pbs.twimg.com/amplify_video_thumb'))) {
- 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');
- }
- // Log both the original and final URLs for debugging
- //console.log(`[Tweet ${tweetIdForDebug}] Processing media: ${sourceUrl} → ${finalUrl}`);
- mediaLinks.add(finalUrl);
- } catch (error) {
- //console.error(`[Tweet ${tweetIdForDebug}] Error processing media URL: ${sourceUrl}`, 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) {
- 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);
- }
- else if (node.querySelectorAll) {
- const tweetsInside = node.querySelectorAll(TWEET_ARTICLE_SELECTOR);
- tweetsInside.forEach(scheduleTweetProcessing);
- }
- }
- });
- }
- // 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();
- //console.debug(`Removed description box ${descId} for tweet that was removed from the DOM`);
- }
- }
- });
- }
- });
- }
- }
- }
- }
- // ----- 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 || '0', 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
- if (tweetArticle.dataset.ratingStatus === 'pending') {
- tweetArticle.style.display = '';
- } else if (isNaN(score) || score < currentFilterThreshold) {
- tweetArticle.style.display = 'none';
- } else {
- tweetArticle.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)) {
- //console.debug(`Blacklisted user detected: ${userHandle}, assigning score 10`);
- tweetArticle.dataset.sloppinessScore = '10';
- tweetArticle.dataset.blacklisted = 'true';
- tweetArticle.dataset.ratingStatus = 'blacklisted';
- tweetArticle.dataset.ratingDescription = 'Whtielisted user';
- setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is whitelisted");
- filterSingleTweet(tweetArticle);
- return true;
- }
- // Check ID-based cache
- if (tweetIDRatingCache[tweetId]) {
- const score = tweetIDRatingCache[tweetId].score;
- const desc = tweetIDRatingCache[tweetId].description;
- //console.debug(`Applied cached rating for tweet ${tweetId}: ${score}`);
- tweetArticle.dataset.sloppinessScore = score.toString();
- tweetArticle.dataset.cachedRating = 'true';
- tweetArticle.dataset.ratingStatus = 'cached';
- tweetArticle.dataset.ratingDescription = desc;
- setScoreIndicator(tweetArticle, score, 'cached', desc);
- filterSingleTweet(tweetArticle);
- return true;
- }
- return false;
- }
- // ----- UI Helper Functions -----
- /**
- * Saves the tweet ratings (by tweet ID) to persistent storage.
- */
- function saveTweetRatings() {
- GM_setValue('tweetRatings', JSON.stringify(tweetIDRatingCache));
- //console.log(`Saved ${Object.keys(tweetIDRatingCache).length} tweet ratings to storage`);
- }
- /**
- * 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";
- setScoreIndicator(tweetArticle, 5, 'error', "No API key");
- filterSingleTweet(tweetArticle);
- return;
- }
- let score = 5; // Default score if rating fails
- let description = "";
- try {
- // Get user handle
- const handles = getUserHandles(tweetArticle);
- const userHandle = handles.length > 0 ? handles[0] : '';
- const quotedHandle = handles.length > 1 ? handles[1] : '';
- const allMediaLinks = extractMediaLinks(tweetArticle);
- // 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";
- setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is blacklisted");
- filterSingleTweet(tweetArticle);
- return;
- }
- // Check if a cached rating exists
- if (applyTweetCachedRating(tweetArticle)) {
- return;
- }
- const fullContextWithImageDescription = await getFullContext(tweetArticle, tweetId, apiKey);
- // --- API Call or Fallback ---
- if (apiKey && fullContextWithImageDescription) {
- try {
- const rating = await rateTweetWithOpenRouter(fullContextWithImageDescription, tweetId, apiKey, allMediaLinks);
- score = rating.score;
- description = rating.content;
- tweetArticle.dataset.ratingStatus = rating.error ? 'error' : 'rated';
- tweetArticle.dataset.ratingDescription = description || "not available";
- tweetArticle.dataset.sloppinessScore = score.toString();
- setScoreIndicator(tweetArticle, score, tweetArticle.dataset.ratingStatus, tweetArticle.dataset.ratingDescription);
- filterSingleTweet(tweetArticle);
- } catch (apiError) {
- score = Math.floor(Math.random() * 10) + 1; // Fallback to a random score
- tweetArticle.dataset.ratingStatus = 'error';
- }
- } else {
- // If there's no API key or textual content (e.g., only media), use a fallback random score.
- score = Math.floor(Math.random() * 10) + 1;
- tweetArticle.dataset.ratingStatus = 'rated';
- }
- tweetArticle.dataset.sloppinessScore = score.toString();
- filterSingleTweet(tweetArticle);
- // Log all collected information at once
- console.log(`Tweet ${tweetId}:
- ${fullContextWithImageDescription} - ${score} Model response: - ${description}`);
- } catch (error) {
- if (!tweetArticle.dataset.sloppinessScore) {
- tweetArticle.dataset.sloppinessScore = '5';
- tweetArticle.dataset.ratingStatus = 'error';
- tweetArticle.dataset.ratingDescription = "error processing tweet";
- console.error(`Error processing tweet ${tweetId}: ${error}`);
- setScoreIndicator(tweetArticle, 5, 'error', 'Error processing tweet');
- filterSingleTweet(tweetArticle);
- }
- }
- }
- /**
- * Schedules processing of a tweet if it hasn't been processed yet.
- * @param {Element} tweetArticle - The tweet element.
- */
- function scheduleTweetProcessing(tweetArticle) {
- const tweetId = getTweetID(tweetArticle);
- // 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 = 'rated';
- tweetArticle.dataset.ratingDescription = "Whitelisted user";
- setScoreIndicator(tweetArticle, 10, 'blacklisted', "User is whitelisted");
- filterSingleTweet(tweetArticle);
- return;
- }
- // If a cached rating is available, use it immediately
- if (tweetIDRatingCache[tweetId]) {
- applyTweetCachedRating(tweetArticle);
- return;
- }
- // Skip if already processed in this session
- if (processedTweets.has(tweetId)) {
- return;
- }
- // Immediately mark as pending before scheduling actual processing
- processedTweets.add(tweetId);
- tweetArticle.dataset.ratingStatus = 'pending';
- setScoreIndicator(tweetArticle, null, 'pending');
- // Now schedule the actual rating processing
- setTimeout(() => { delayedProcessTweet(tweetArticle, tweetId); }, PROCESSING_DELAY_MS);
- }
- /**
- * 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));
- let allMediaLinks = extractMediaLinks(tweetArticle);
- // --- Extract Quoted Tweet Content (if any) ---
- let quotedText = "";
- let quotedMediaLinks = [];
- const quoteContainer = tweetArticle.querySelector(QUOTE_CONTAINER_SELECTOR);
- if (quoteContainer) {
- quotedText = getElementText(quoteContainer.querySelector(TWEET_TEXT_SELECTOR));
- quotedMediaLinks = extractMediaLinks(quoteContainer);
- }
- // Remove any media links from the main tweet that also appear in the quoted tweet
- let mainMediaLinks = allMediaLinks.filter(link => !quotedMediaLinks.includes(link));
- let fullContextWithImageDescription = `[TWEET ${tweetId}]
- Author:@${userHandle}:
- ` + mainText;
- if (mainMediaLinks.length > 0) {
- // Process main tweet images only if image descriptions are enabled
- if (enableImageDescriptions) {
- 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(", ")}`;
- }
- // --- Quoted Tweet Handling ---
- if (quotedText) {
- fullContextWithImageDescription += `
- [QUOTED_TWEET]:
- 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}`;
- } else {
- // Just add the URLs when descriptions are disabled
- fullContextWithImageDescription += `
- [QUOTED_TWEET_MEDIA_URLS]:
- ${quotedMediaLinks.join(", ")}`;
- }
- }
- }
- // --- Conversation Thread Handling ---
- const conversation = document.querySelector('div[aria-label="Timeline: Conversation"]');
- 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;
- }
- }
- 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);
- }
- /**
- * Periodically checks and processes tweets that might have been added without triggering mutations.
- */
- function ensureAllTweetsRated() {
- if (!observedTargetNode) return;
- const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR);
- tweets.forEach(tweet => {
- if (!tweet.dataset.sloppinessScore) {
- scheduleTweetProcessing(tweet);
- }
- });
- }
- async function handleThreads() {
- let conversation = document.querySelector('div[aria-label="Timeline: Conversation"]');
- if (conversation) {
- if (conversation.dataset.threadHist == undefined) {
- threadHist = "";
- const firstArticle = document.querySelector('article[data-testid="tweet"]');
- if (firstArticle) {
- conversation.dataset.threadHist = 'pending';
- const tweetId = getTweetID(firstArticle);
- if (tweetIDRatingCache[tweetId]) {
- threadHist = tweetIDRatingCache[tweetId].tweetContent;
- } else {
- const apiKey = GM_getValue('openrouter-api-key', '');
- const fullcxt = await getFullContext(firstArticle, tweetId, apiKey);
- threadHist = fullcxt;
- }
- conversation.dataset.threadHist = threadHist;
- //this lets us know if we are still on the main post of the conversation or if we are on a reply to the main post. Will disapear every time we dive deeper
- conversation.firstChild.dataset.canary = "true";
- }
- }
- else if (conversation.dataset.threadHist == "pending") {
- return;
- }
- else if (conversation.dataset.threadHist != "pending" && conversation.firstChild.dataset.canary == undefined) {
- conversation.firstChild.dataset.canary = "pending";
- const nextArticle = document.querySelector('article[data-testid="tweet"]:has(~ div[data-testid="inline_reply_offscreen"])');
- const tweetId = getTweetID(nextArticle);
- if (tweetIDRatingCache[tweetId]) {
- threadHist = threadHist + "\n[REPLY]\n" + tweetIDRatingCache[tweetId].tweetContent;
- } else {
- const apiKey = GM_getValue('openrouter-api-key', '');
- await new Promise(resolve => setTimeout(resolve, 500));
- const newContext = await getFullContext(nextArticle, tweetId, apiKey);
- threadHist = threadHist + "\n[REPLY]\n" + newContext;
- conversation.dataset.threadHist = threadHist;
- }
- }
- }
- }
- // ----- ui.js -----
- // --- Constants ---
- const VERSION = '1.3'; // Update version here
- // --- 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) {
- console.error('#status-indicator element not found.');
- 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) {
- console.error('Failed to load Menu.html resource!');
- 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) {
- console.warn('UI container already exists. Skipping injection.');
- 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);
- console.log('Injected styles from Menu.html');
- stylesheet.remove(); // Remove style tag after injecting
- } else {
- console.warn('No <style> tag found in Menu.html');
- }
- // Append the rest of the UI elements
- document.body.appendChild(uiContainer);
- console.log('TweetFilter UI Injected from HTML resource.');
- // 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) {
- console.error('UI Container not found for event listeners.');
- return;
- }
- console.log('Wiring UI events...');
- 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);
- console.log('UI events wired.');
- }
- // --- 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
- } else {
- showStatus('Please enter a valid API key');
- }
- }
- /** Clears tweet ratings and updates the relevant UI parts. */
- function clearTweetRatingsAndRefreshUI() {
- if (confirm('Are you sure you want to clear all cached tweet ratings?')) {
- Object.keys(tweetIDRatingCache).forEach(key => delete tweetIDRatingCache[key]);
- GM_setValue('tweetRatings', '{}');
- showStatus('All cached ratings cleared!');
- console.log('Cleared all tweet ratings');
- 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);
- });
- }
- }
- }
- /** 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 (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'.
- * @param {string} [description] - Optional description for hover tooltip.
- */
- function setScoreIndicator(tweetArticle, score, status, description = "") {
- 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);
- // Add hover listeners only once when creating the indicator
- indicator.addEventListener('mouseenter', handleIndicatorMouseEnter);
- indicator.addEventListener('mouseleave', handleIndicatorMouseLeave);
- }
- // Update status class and text content
- indicator.classList.remove('pending-rating', 'rated-rating', 'error-rating', 'cached-rating', 'blacklisted-rating'); // Clear previous
- indicator.dataset.description = description || ''; // Store description
- switch (status) {
- case 'pending':
- indicator.classList.add('pending-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;
- }
- }
- /** Global tooltip element */
- let scoreTooltip = null;
- /** Creates or gets the shared tooltip element. */
- function getScoreTooltip() {
- if (!scoreTooltip) {
- scoreTooltip = document.createElement('div');
- scoreTooltip.className = 'score-description'; // Use the class from HTML/CSS
- scoreTooltip.style.display = 'none'; // Initially hidden
- scoreTooltip.style.position = 'fixed'; // Use fixed positioning
- scoreTooltip.style.zIndex = '99999999';
- document.body.appendChild(scoreTooltip);
- // Keep tooltip visible when hovering over it
- scoreTooltip.addEventListener('mouseenter', () => {
- scoreTooltip.style.display = 'block';
- });
- scoreTooltip.addEventListener('mouseleave', () => {
- scoreTooltip.style.display = 'none';
- });
- }
- return scoreTooltip;
- }
- /** Formats description text for the tooltip. */
- function formatTooltipDescription(description) {
- if (!description) return '';
- // Basic formatting, can be expanded
- description = description.replace(/\{score:\s*(\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, '</p><p style="margin-top: 10px;">'); // Smaller margin
- description = description.replace(/\n/g, '<br>');
- return `<p>${description}</p>`;
- }
- /** Handles mouse enter event for score indicators. */
- function handleIndicatorMouseEnter(event) {
- const indicator = event.target;
- const description = indicator.dataset.description;
- if (!description) return;
- const tooltip = getScoreTooltip();
- tooltip.innerHTML = formatTooltipDescription(description);
- // Position the tooltip
- const rect = indicator.getBoundingClientRect();
- const tooltipWidth = tooltip.offsetWidth; // Get width after setting content
- const tooltipHeight = tooltip.offsetHeight;
- const margin = 10;
- let left = rect.right + margin;
- let top = rect.top + (rect.height / 2) - (tooltipHeight / 2);
- // Adjust if going off-screen
- if (left + tooltipWidth > window.innerWidth - margin) {
- left = rect.left - tooltipWidth - margin;
- }
- if (top < margin) {
- top = margin;
- }
- if (top + tooltipHeight > window.innerHeight - margin) {
- top = window.innerHeight - tooltipHeight - margin;
- }
- tooltip.style.left = `${left}px`;
- tooltip.style.top = `${top}px`;
- tooltip.style.display = 'block';
- }
- /** Handles mouse leave event for score indicators. */
- function handleIndicatorMouseLeave() {
- const tooltip = getScoreTooltip();
- // Hide with a slight delay to allow moving cursor to the tooltip
- setTimeout(() => {
- if (tooltip && tooltip.style.display !== 'none' && !tooltip.matches(':hover')) {
- tooltip.style.display = 'none';
- }
- }, 100);
- }
- /** Cleans up the global score tooltip element. */
- function cleanupDescriptionElements() {
- if (scoreTooltip) {
- scoreTooltip.remove();
- scoreTooltip = null;
- }
- }
- // --- 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', 'google/gemini-flash-1.5-8b'),
- selectedImageModel: GM_getValue('selectedImageModel', 'google/gemini-flash-1.5-8b'),
- enableImageDescriptions: GM_getValue('enableImageDescriptions', false),
- 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) {
- console.error('Error exporting settings:', 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) {
- console.error('Error parsing settings file:', error);
- showStatus('Error importing settings: ' + error.message);
- }
- };
- reader.readAsText(file);
- };
- input.click();
- } catch (error) {
- console.error('Error importing settings:', 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: 'google/gemini-flash-1.5-8b',
- selectedImageModel: 'google/gemini-flash-1.5-8b',
- enableImageDescriptions: false,
- modelTemperature: 0.5,
- modelTopP: 0.9,
- imageModelTemperature: 0.5,
- imageModelTopP: 0.9,
- maxTokens: 0,
- filterThreshold: 1,
- 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 {
- console.warn(`Attempted to remove non-existent handle: ${handle}`);
- }
- }
- // --- Initialization ---
- /**
- * Main initialization function for the UI module.
- */
- function initialiseUI() {
- const uiContainer = injectUI();
- if (!uiContainer) return; // Stop if injection failed
- initializeEventListeners(uiContainer);
- refreshSettingsUI(); // Set initial state from saved settings
- fetchAvailableModels(); // Fetch models async
- }
- })();