TweetFilter AI

A highly customizable AI rates tweets 1-10 and removes all the slop, saving your braincells!

  1. // ==UserScript==
  2. // @name TweetFilter AI
  3. // @namespace http://tampermonkey.net/
  4. // @version Version 1.5
  5. // @description A highly customizable AI rates tweets 1-10 and removes all the slop, saving your braincells!
  6. // @author Obsxrver(3than)
  7. // @match *://twitter.com/*
  8. // @match *://x.com/*
  9. // @grant GM_addStyle
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // @grant GM_xmlhttpRequest
  13. // @grant GM_getResourceText
  14. // @connect openrouter.ai
  15. // @run-at document-idle
  16. // @license MIT
  17. // ==/UserScript==
  18. (function() {
  19. 'use strict';
  20. // Embedded Menu.html
  21. const MENU = `<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><div class="filter-controls"><input type="range" id="tweet-filter-slider" min="0" max="10" step="1"><input type="number" id="tweet-filter-value" min="0" max="10" step="1" value="5"></div></div><button id="settings-toggle" class="toggle-button" data-action="toggle-settings"><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="toggle-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/settings/keys" 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 &amp; Restore</div><div class="section-description">Export your settings and cached ratings to a file for backup, or import previously saved settings.</div><button class="settings-button" data-action="export-cache">Export Cache</button><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">The rating model is responsible for reviewing each tweet. <br>It will process images directly if you select an <strong>image-capable (🖼️)</strong> model.</div><div class="select-container" id="model-select-container"></div><div class="advanced-options"><div class="advanced-toggle" data-toggle="model-options-content"><div class="advanced-toggle-title">Options</div><div class="advanced-toggle-icon">▼</div></div><div class="advanced-content" id="model-options-content"><div class="sort-container"><label for="model-sort-order">Sort models by: </label><div class="controls-group"><select id="model-sort-order" data-setting="modelSortOrder"><option value="pricing-low-to-high">Price</option><option value="latency-low-to-high">Latency</option><option value="throughput-high-to-low">Throughput</option><option value="top-weekly">Popularity</option><option value="">Age</option></select><button id="sort-direction" class="sort-toggle" data-setting="sortDirection" data-value="default">High-Low</button></div></div><div class="sort-container"><label for="provider-sort">API Endpoint Priority: </label><select id="provider-sort" data-setting="providerSort"><option value="">Default (load-balanced)</option><option value="throughput">Throughput</option><option value="latency">Latency</option><option value="price">Price</option></select></div><div class="sort-container"><label><input type="checkbox" id="show-free-models" data-setting="showFreeModels" checked>Show Free Models</label></div><div class="parameter-row" data-param-name="modelTemperature"><div class="parameter-label" title="How random the model responses should be (0.0-1.0)">Temperature</div><div class="parameter-control"><input type="range" class="parameter-slider" min="0" max="2" step="0.1"><input type="number" class="parameter-value" min="0" max="2" step="0.01" 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.01" style="width: 60px;"></div></div><div class="parameter-row" data-param-name="maxTokens"><div class="parameter-label" title="Maximum number of tokens for the response (0 means no limit)">Max Tokens</div><div class="parameter-control"><input type="range" class="parameter-slider" min="0" max="2000" step="100"><input type="number" class="parameter-value" min="0" max="2000" step="100" style="width: 60px;"></div></div><div class="toggle-row"><div class="toggle-label" title="Stream API responses as they're generated for live updates">Enable Live Streaming</div><label class="toggle-switch"><input type="checkbox" data-setting="enableStreaming"><span class="toggle-slider"></span></label></div></div></div><div class="section-title" style="margin-top: 25px;"><span style="font-size: 14px;">🖼️</span> Image Processing Model</div><div class="section-description">This model generates <strong>text descriptions</strong> of images for the rating model.<br> Hint: If you selected an image-capable model (🖼️) as your <strong>main rating model</strong>, it will 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="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">Options</div><div class="advanced-toggle-icon">▼</div></div><div class="advanced-content" id="image-advanced-content"><div class="parameter-row" data-param-name="imageModelTemperature"><div class="parameter-label" title="Randomness for image descriptions (0.0-1.0)">Temperature</div><div class="parameter-control"><input type="range" class="parameter-slider" min="0" max="2" step="0.1"><input type="number" class="parameter-value" min="0" max="2" step="0.1" style="width: 60px;"></div></div><div class="parameter-row" data-param-name="imageModelTopP"><div class="parameter-label" title="Nucleus sampling for image model (0.0-1.0)">Top-p</div><div class="parameter-control"><input type="range" class="parameter-slider" min="0" max="1" step="0.1"><input type="number" class="parameter-value" min="0" max="1" step="0.1" style="width: 60px;"></div></div></div></div></div></div><div id="instructions-tab" class="tab-content"><div class="section-title">Custom 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="advanced-options" id="instructions-history"><div class="advanced-toggle" data-toggle="instructions-history-content"><div class="advanced-toggle-title">Custom Instructions History</div><div class="advanced-toggle-icon">▼</div></div><div class="advanced-content" id="instructions-history-content"><div class="instructions-list" id="instructions-list"><!-- Instructions entries will be added here dynamically --></div><button class="settings-button danger" style="margin-top: 10px;" data-action="clear-instructions-history">Clear All History</button></div></div><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 class="handle-input-container"><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 id="tweet-filter-stats-badge" class="tweet-filter-stats-badge"></div></div>`;
  22. // Embedded style.css
  23. const STYLE = `.refreshing {animation: spin 1s infinite linear;}@keyframes spin {0% {transform: rotate(0deg);}100% {transform: rotate(360deg);}}.score-highlight {display: inline-block;background-color: #1d9bf0;/* Twitter blue */color: white;padding: 3px 10px;border-radius: 9999px;margin: 8px 0;font-weight: bold;font-size: 0.9em;}.mobile-tooltip {/* Add specific mobile tooltip styles if needed */max-width: 90vw;/* Example */}.score-description.streaming-tooltip {scroll-behavior: smooth;border-left: 3px solid #1d9bf0;background-color: rgba(25, 30, 35, 0.98);}.score-description.streaming-tooltip::before {content: 'Live';position: absolute;top: 10px;right: 10px;background-color: #1d9bf0;color: white;font-size: 11px;padding: 2px 6px;border-radius: 10px;font-weight: bold;}.score-description::-webkit-scrollbar {width: 6px;}.score-description::-webkit-scrollbar-track {background: rgba(255, 255, 255, 0.05);border-radius: 3px;}.score-description::-webkit-scrollbar-thumb {background: rgba(255, 255, 255, 0.2);border-radius: 3px;}.score-description::-webkit-scrollbar-thumb:hover {background: rgba(255, 255, 255, 0.3);}.score-description.streaming-tooltip p::after {content: '|';display: inline-block;color: #1d9bf0;animation: blink 0.7s infinite;font-weight: bold;margin-left: 2px;}@keyframes blink {0%,100% {opacity: 0;}50% {opacity: 1;}}.streaming-rating {background-color: rgba(33, 150, 243, 0.9) !important;color: white !important;animation: pulse 1.5s infinite alternate;position: relative;}.streaming-rating::after {content: '';position: absolute;top: -2px;right: -2px;width: 6px;height: 6px;background-color: #1d9bf0;border-radius: 50%;animation: blink 0.7s infinite;box-shadow: 0 0 4px #1d9bf0;}.cached-rating {background-color: rgba(76, 175, 80, 0.9) !important;color: white !important;}.rated-rating {background-color: rgba(33, 33, 33, 0.9) !important;color: white !important;}.blacklisted-rating {background-color: rgba(255, 193, 7, 0.9) !important;color: black !important;}.pending-rating {background-color: rgba(255, 152, 0, 0.9) !important;color: white !important;}@keyframes pulse {0% {opacity: 0.8;}100% {opacity: 1;}}.error-rating {background-color: rgba(244, 67, 54, 0.9) !important;color: white !important;}#status-indicator {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 {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;}#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);transform-origin: top right;transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55), opacity 0.5s ease-in-out;opacity: 1;transform: scale(1) translateX(0);visibility: visible;}#tweet-filter-container.hidden {opacity: 0;transform: scale(0.8) translateX(50px);visibility: hidden;}.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%;min-width: 28px;min-height: 28px;-webkit-tap-highlight-color: transparent;touch-action: manipulation;user-select: none;z-index: 30;}.close-button:hover {opacity: 1;background-color: rgba(255, 255, 255, 0.1);}.hidden {display: none !important;}/* Only override hidden for our specific containers */#tweet-filter-container.hidden,#settings-container.hidden {display: flex !important;}.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-stats-badge {position: fixed;bottom: 50px;right: 20px;background-color: rgba(29, 155, 240, 0.9);color: white;padding: 5px 10px;border-radius: 15px;font-size: 12px;z-index: 9999;box-shadow: 0 2px 5px rgba(0,0,0,0.2);transition: opacity 0.3s;cursor: pointer;display: flex;align-items: center;}#tweet-filter-slider {cursor: pointer;width: 120px;vertical-align: middle;-webkit-appearance: none;appearance: none;height: 6px;border-radius: 3px;background: linear-gradient(to right,#FF0000 0%,#FF8800 calc(var(--slider-percent, 50%) * 0.166),#FFFF00 calc(var(--slider-percent, 50%) * 0.333),#00FF00 calc(var(--slider-percent, 50%) * 0.5),#00FFFF calc(var(--slider-percent, 50%) * 0.666),#0000FF calc(var(--slider-percent, 50%) * 0.833),#800080 var(--slider-percent, 50%),#DEE2E6 var(--slider-percent, 50%),#DEE2E6 100%);}#tweet-filter-slider::-webkit-slider-thumb {-webkit-appearance: none;appearance: none;width: 16px;height: 16px;border-radius: 50%;background: #1d9bf0;cursor: pointer;border: 2px solid white;box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);transition: transform 0.1s;}#tweet-filter-slider::-webkit-slider-thumb:hover {transform: scale(1.2);}#tweet-filter-slider::-moz-range-thumb {width: 16px;height: 16px;border-radius: 50%;background: #1d9bf0;cursor: pointer;border: 2px solid white;box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);transition: transform 0.1s;}#tweet-filter-slider::-moz-range-thumb:hover {transform: scale(1.2);}#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-container {position: fixed;top: 70px;right: 15px;background-color: rgba(22, 24, 28, 0.95);color: #e7e9ea;padding: 0;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: 90vw;max-width: 380px;max-height: 85vh;overflow: hidden;border: 1px solid rgba(255, 255, 255, 0.1);line-height: 1.3;transform-origin: top right;transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55),opacity 0.5s ease-in-out;opacity: 1;transform: scale(1) translateX(0);visibility: visible;}#settings-container.hidden {opacity: 0;transform: scale(0.8) translateX(50px);visibility: hidden;}.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;}.settings-content {overflow-y: auto;max-height: calc(85vh - 110px);padding: 0;}.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 {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 {display: none;animation: fadeIn 0.3s ease;padding: 15px;}@keyframes fadeIn {from {opacity: 0;}to {opacity: 1;}}.tab-content.active {display: block;}.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);}.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);}#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-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-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;}.section-title a {color: #1d9bf0;text-decoration: none;background-color: rgba(255, 255, 255, 0.1);padding: 3px 6px;border-radius: 6px;transition: all 0.2s ease;}.section-title a:hover {background-color: rgba(29, 155, 240, 0.2);text-decoration: underline;}.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: none;}#instructions-history-content.expanded {max-height: none !important;}#instructions-history .instructions-list {max-height: 400px;overflow-y: auto;margin-bottom: 10px;}.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;}.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;}.button-row {display: flex;gap: 8px;margin-top: 10px;}.button-row .settings-button {margin-top: 0;}.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;}.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);}.score-indicator.mobile-indicator {position: absolute !important;bottom: 3% !important;right: 10px !important;top: auto !important;}.score-description {display: none;background-color: rgba(22, 24, 28, 0.95);color: #e7e9ea;padding: 0 20px 16px 20px;border-radius: 12px;box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;font-size: 16px;line-height: 1.5;z-index: 99999999;position: absolute;width: 550px !important;max-width: 80vw !important;max-height: 60vh;overflow-y: auto;border: 1px solid rgba(255, 255, 255, 0.1);word-wrap: break-word;box-sizing: border-box !important;max-width: 500px;padding-bottom: 35px;}.score-description.pinned {border: 2px solid #1d9bf0 !important;}.tooltip-controls {display: flex !important;justify-content: flex-end !important;position: relative !important;margin: 0 -20px 15px -20px !important;top: 0 !important;background-color: rgba(39, 44, 48, 0.95) !important;padding: 12px 15px !important;z-index: 2 !important;border-top-left-radius: 12px !important;border-top-right-radius: 12px !important;border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important;backdrop-filter: blur(5px) !important;}.tooltip-pin-button,.tooltip-copy-button {background: none !important;border: none !important;color: #8899a6 !important;cursor: pointer !important;font-size: 16px !important;padding: 4px 8px !important;margin-left: 8px !important;border-radius: 4px !important;transition: all 0.2s !important;}.tooltip-pin-button:hover,.tooltip-copy-button:hover {background-color: rgba(29, 155, 240, 0.1) !important;color: #1d9bf0 !important;}.tooltip-pin-button:active,.tooltip-copy-button:active {transform: scale(0.95) !important;}.description-text {margin: 0 0 25px 0 !important;font-size: 15px !important;line-height: 1.6 !important;max-width: 100% !important;overflow-wrap: break-word !important;padding: 5px 0 !important;color: #e1e8ed !important;letter-spacing: 0.2px !important;}/* Style different elements within description text */.description-text p {margin: 0 0 12px 0 !important;}.description-text p:last-child {margin-bottom: 0 !important;}.description-text strong {color: #fff !important;font-weight: 600 !important;}.description-text em {color: #8899a6 !important;font-style: italic !important;}.description-text code {background: rgba(255, 255, 255, 0.1) !important;padding: 2px 6px !important;border-radius: 4px !important;font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;font-size: 0.9em !important;color: #1d9bf0 !important;}.description-text a {color: #1d9bf0 !important;text-decoration: none !important;transition: color 0.2s ease !important;border-bottom: 1px solid rgba(29, 155, 240, 0.3) !important;}.description-text a:hover {color: #ffffff !important;border-bottom-color: #ffffff !important;}/* Add subtle highlight for important sections */.description-text blockquote {margin: 12px 0 !important;padding: 8px 16px !important;border-left: 3px solid #1d9bf0 !important;background: rgba(29, 155, 240, 0.1) !important;border-radius: 0 4px 4px 0 !important;}/* Style lists within description */.description-text ul, .description-text ol {margin: 8px 0 12px 20px !important;padding: 0 !important;}.description-text li {margin: 4px 0 !important;line-height: 1.5 !important;}/* Style headings if present */.description-text h1,.description-text h2,.description-text h3,.description-text h4 {color: #ffffff !important;margin: 16px 0 8px 0 !important;font-weight: 600 !important;line-height: 1.3 !important;}.description-text h1 { font-size: 1.4em !important; }.description-text h2 { font-size: 1.3em !important; }.description-text h3 { font-size: 1.2em !important; }.description-text h4 { font-size: 1.1em !important; }/* Add subtle animation for content changes */.description-text {transition: opacity 0.2s ease !important;}/* Style tables if present */.description-text table {width: 100% !important;border-collapse: collapse !important;margin: 12px 0 !important;background: rgba(255, 255, 255, 0.03) !important;border-radius: 4px !important;}.description-text th,.description-text td {padding: 8px 12px !important;border: 1px solid rgba(255, 255, 255, 0.1) !important;text-align: left !important;}.description-text th {background: rgba(255, 255, 255, 0.05) !important;font-weight: 600 !important;color: #ffffff !important;}/* Add a subtle separator between major sections */.description-text hr {border: none !important;border-top: 1px solid rgba(255, 255, 255, 0.1) !important;margin: 20px 0 !important;}/* Style pre blocks for code snippets */.description-text pre {background: rgba(0, 0, 0, 0.2) !important;padding: 12px !important;border-radius: 4px !important;overflow-x: auto !important;border: 1px solid rgba(255, 255, 255, 0.1) !important;margin: 12px 0 !important;}.description-text pre code {background: none !important;padding: 0 !important;border-radius: 0 !important;font-size: 0.9em !important;color: #e1e8ed !important;}/* Add a subtle hover effect for interactive elements */.description-text *[role="button"],.description-text .interactive {transition: all 0.2s ease !important;}.description-text *[role="button"]:hover,.description-text .interactive:hover {background-color: rgba(255, 255, 255, 0.1) !important;}/* Style keyboard shortcuts or commands */.description-text kbd {background: rgba(255, 255, 255, 0.1) !important;border: 1px solid rgba(255, 255, 255, 0.2) !important;border-radius: 3px !important;padding: 2px 6px !important;font-size: 0.9em !important;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important;box-shadow: 0 1px 1px rgba(0,0,0,0.2) !important;}.tooltip-bottom-spacer {height: 10px;}.reasoning-dropdown {margin-top: 15px !important;border-top: 1px solid rgba(255, 255, 255, 0.1) !important;padding-top: 10px !important;}.reasoning-toggle {display: flex !important;align-items: center !important;color: #1d9bf0 !important;cursor: pointer !important;font-weight: bold !important;padding: 5px !important;user-select: none !important;}.reasoning-toggle:hover {background-color: rgba(29, 155, 240, 0.1) !important;border-radius: 4px !important;}.reasoning-arrow {display: inline-block !important;margin-right: 5px !important;transition: transform 0.2s ease !important;}.reasoning-content {max-height: 0 !important;overflow: hidden !important;transition: max-height 0.3s ease-out, padding 0.3s ease-out !important;background-color: rgba(0, 0, 0, 0.15) !important;border-radius: 5px !important;margin-top: 5px !important;padding: 0 !important;}.reasoning-dropdown.expanded .reasoning-content {max-height: 350px !important;overflow-y: auto !important;padding: 10px !important;}.reasoning-dropdown.expanded .reasoning-arrow {transform: rotate(90deg) !important;}.reasoning-text {font-size: 14px !important;line-height: 1.4 !important;color: #ccc !important;margin: 0 !important;padding: 5px !important;}.scroll-to-bottom-button {position: sticky;bottom: 0;width: 100%;background-color: rgba(29, 155, 240, 0.9);color: white;text-align: center;padding: 8px 0;cursor: pointer;font-weight: bold;border-top: 1px solid rgba(255, 255, 255, 0.2);margin-top: 10px;z-index: 100;transition: background-color 0.2s;}.scroll-to-bottom-button:hover {background-color: rgba(29, 155, 240, 1);}@media (max-width: 600px) {.score-indicator {position: absolute !important;bottom: 3% !important;right: 10px !important;top: auto !important;}.score-description {position: fixed !important;width: 100% !important;max-width: 100% !important;top: 5vh !important;bottom: 5vh !important;left: 0 !important;right: 0 !important;margin: 0 !important;padding: 12px !important;box-sizing: border-box !important;overflow-y: auto !important;overflow-x: hidden !important;-webkit-overflow-scrolling: touch !important;overscroll-behavior: contain !important;transform: translateZ(0) !important;border-radius: 16px 16px 0 0 !important;}.reasoning-dropdown.expanded .reasoning-content {max-height: 200px !important;}.close-button {width: 32px;height: 32px;min-width: 32px;min-height: 32px;font-size: 18px;padding: 8px;margin: -4px;}.settings-header .close-button {position: relative;right: 0;}.tooltip-close-button {font-size: 22px !important;width: 32px !important;height: 32px !important;}.tooltip-controls {padding-right: 40px !important;}}.sort-container {margin: 10px 0;display: flex;align-items: center;gap: 10px;justify-content: space-between;}.sort-container label {font-size: 14px;color: var(--text-color);white-space: nowrap;}.sort-container .controls-group {display: flex;gap: 8px;align-items: center;}.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;min-width: 120px;}.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);}.sort-toggle {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;transition: all 0.2s ease;}.sort-toggle:hover {border-color: #1d9bf0;background-color: rgba(29, 155, 240, 0.1);}.sort-toggle.active {background-color: rgba(29, 155, 240, 0.2);border-color: #1d9bf0;}.sort-container select option {background-color: rgba(39, 44, 48, 0.95);color: #e7e9ea;}@media (min-width: 601px) {#settings-container {width: 480px;max-width: 480px;}}#handle-input {flex: 1;padding: 8px 12px;border-radius: 8px;border: 1px solid rgba(255, 255, 255, 0.2);background-color: rgba(39, 44, 48, 0.95);color: #e7e9ea;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;font-size: 14px;transition: border-color 0.2s;min-width: 200px;}#handle-input:focus {outline: none;border-color: #1d9bf0;box-shadow: 0 0 0 2px rgba(29, 155, 240, 0.2);}#handle-input::placeholder {color: rgba(231, 233, 234, 0.5);}.handle-input-container {display: flex;gap: 8px;align-items: center;margin-bottom: 10px;padding: 5px;border-radius: 8px;background-color: rgba(255, 255, 255, 0.03);}.add-handle-btn {background-color: #1d9bf0;color: white;border: none;border-radius: 8px;padding: 8px 16px;cursor: pointer;font-weight: bold;font-size: 14px;transition: background-color 0.2s;white-space: nowrap;}.add-handle-btn:hover {background-color: #1a8cd8;}.instructions-list {margin-top: 10px;max-height: 200px;overflow-y: auto;border: 1px solid rgba(255, 255, 255, 0.1);border-radius: 8px;padding: 5px;}.instruction-item {display: flex;align-items: center;justify-content: space-between;padding: 8px 10px;border-bottom: 1px solid rgba(255, 255, 255, 0.05);border-radius: 4px;transition: background-color 0.2s;}.instruction-item:hover {background-color: rgba(255, 255, 255, 0.05);}.instruction-item:last-child {border-bottom: none;}.instruction-text {font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;font-size: 12px;flex: 1;margin-right: 10px;}.instruction-buttons {display: flex;gap: 5px;}.use-instruction {background: none;border: none;color: #1d9bf0;cursor: pointer;font-size: 12px;padding: 3px 8px;border-radius: 4px;transition: all 0.2s;}.use-instruction:hover {background-color: rgba(29, 155, 240, 0.1);}.remove-instruction {background: none;border: none;color: #ff5c5c;cursor: pointer;font-size: 14px;padding: 0 3px;opacity: 0.7;transition: opacity 0.2s;border-radius: 4px;}.remove-instruction:hover {opacity: 1;background-color: rgba(255, 92, 92, 0.1);}.tweet-filtered {display: none !important;visibility: hidden !important;opacity: 0 !important;pointer-events: none !important;/* Ensure it stays hidden even if Twitter tries to show it */position: absolute !important;z-index: -9999 !important;height: 0 !important;width: 0 !important;margin: 0 !important;padding: 0 !important;overflow: hidden !important;}.filter-controls {display: flex;align-items: center;gap: 10px;margin: 5px 0;}.filter-controls input[type="range"] {flex: 1;min-width: 100px;}.filter-controls input[type="number"] {width: 50px;padding: 2px 5px;border: 1px solid #ccc;border-radius: 4px;text-align: center;}/* Hide number input spinners */.filter-controls input[type="number"]::-webkit-inner-spin-button,.filter-controls input[type="number"]::-webkit-outer-spin-button {-webkit-appearance: none;margin: 0;}.filter-controls input[type="number"] {-moz-appearance: textfield;}/* --- Metadata Specific Styling --- */.tooltip-metadata {font-size: 0.8em;opacity: 0.7;margin-top: 8px;padding-top: 8px;border-top: 1px solid rgba(255, 255, 255, 0.2);display: block;line-height: 1.5;}.metadata-line {white-space: nowrap;overflow: hidden;text-overflow: ellipsis;margin-bottom: 2px;}.metadata-separator {display: none;}/* --- Specific Indicator Styles --- */.score-indicator.pending-rating {}/* --- Tooltip Styles --- */.score-description {/* ... existing styles ... */max-width: 500px;padding-bottom: 35px; /* Add padding for scroll button *//* ... existing styles ... */}.score-description.streaming-tooltip {border-color: #ffa500; /* Orange border for streaming */}/* ... existing .tooltip-controls, .tooltip-pin-button, .tooltip-copy-button styles ... *//* --- Reasoning Dropdown --- */.reasoning-dropdown {/* ... existing styles ... */}.reasoning-toggle {/* ... existing styles ... */}.reasoning-arrow {/* ... existing styles ... */}.reasoning-content {/* ... existing styles ... */}.reasoning-text {/* ... existing styles ... */}.description-text {/* ... existing styles ... */}/* --- Last Answer Area --- */.tooltip-last-answer {margin-top: 10px;padding: 10px;background-color: rgba(255, 255, 255, 0.05); /* Slightly different background */border-radius: 4px;font-size: 0.9em;line-height: 1.4;}.answer-separator {border: none;border-top: 1px dashed rgba(255, 255, 255, 0.2);margin: 10px 0;}/* --- Follow-Up Questions Area --- */.tooltip-follow-up-questions {margin-top: 10px;display: flex;flex-direction: column;gap: 6px; /* Spacing between buttons */}.follow-up-question-button {background-color: rgba(60, 160, 240, 0.2); /* Light blue background */border: 1px solid rgba(60, 160, 240, 0.5);color: #e1e8ed; /* Light text */padding: 8px 12px;border-radius: 15px; /* Pill shape */cursor: pointer;font-size: 0.85em;text-align: left;transition: background-color 0.2s ease, border-color 0.2s ease;white-space: normal; /* Allow wrapping */line-height: 1.3;}.follow-up-question-button:hover {background-color: rgba(60, 160, 240, 0.35);border-color: rgba(60, 160, 240, 0.8);}.follow-up-question-button:active {background-color: rgba(60, 160, 240, 0.5);}/* --- Metadata Area --- */.tooltip-metadata {margin-top: 12px;padding-top: 8px;font-size: 0.8em;color: #8899a6; /* Muted color */border-top: 1px solid rgba(255, 255, 255, 0.1);}.metadata-separator {border: none;border-top: 1px dashed rgba(255, 255, 255, 0.2);margin: 8px 0;}.metadata-line {margin-bottom: 4px;}.metadata-line:last-child {margin-bottom: 0;}/* --- Score Highlight --- */.score-highlight {/* ... existing styles ... */}/* --- Scroll Button --- */.scroll-to-bottom-button {/* ... existing styles ... */}.tooltip-bottom-spacer {/* ... existing styles ... */}/* --- Custom Question Input Area --- */.tooltip-custom-question-container {margin-top: 15px;display: flex;gap: 8px;padding-top: 10px;border-top: 1px solid rgba(255, 255, 255, 0.1); /* Separator */}.tooltip-custom-question-input {flex-grow: 1; /* Take available space */padding: 8px 10px;border-radius: 6px;border: 1px solid rgba(255, 255, 255, 0.2);background-color: rgba(39, 44, 48, 0.9);color: #e7e9ea;font-size: 0.9em;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; /* Ensure font consistency */line-height: 1.4; /* Adjust line height for better text readability */resize: none; /* Prevent manual resizing by the user */overflow-y: hidden; /* Hide scrollbar initially, height adjustment will manage overflow */min-height: calc(0.9em * 1.4 + 16px + 2px); /* Approx height for 1 row: (font-size * line-height) + padding + border */box-sizing: border-box; /* Ensure padding and border are included in height calculation */}.tooltip-custom-question-input:focus {border-color: #1d9bf0;outline: none;}.tooltip-custom-question-button {background-color: #1d9bf0;color: white;border: none;border-radius: 6px;padding: 8px 12px;cursor: pointer;font-weight: bold;font-size: 0.9em;transition: background-color 0.2s;}.tooltip-custom-question-button:hover {background-color: #1a8cd8;}.tooltip-custom-question-button:disabled,.tooltip-custom-question-input:disabled {opacity: 0.6;cursor: not-allowed;}/* --- Conversation History Styling --- */.tooltip-conversation-history {margin-top: 15px;padding-top: 10px;border-top: 1px solid rgba(255, 255, 255, 0.1);display: flex;flex-direction: column;gap: 12px; /* Space between conversation turns */}.conversation-turn {background-color: rgba(255, 255, 255, 0.04);padding: 10px;border-radius: 6px;line-height: 1.4;}.conversation-question {font-size: 0.9em;color: #b0bec5; /* Lighter grey for user question */margin-bottom: 6px;}.conversation-question strong {color: #cfd8dc; /* Slightly brighter for "You:" */}.conversation-answer {font-size: 0.95em;color: #e1e8ed; /* Main text color for AI answer */}.conversation-answer strong {color: #1d9bf0; /* Twitter blue for "AI:" */}.conversation-separator {border: none;border-top: 1px dashed rgba(255, 255, 255, 0.15);margin: 0; /* Reset margin, gap handles spacing */}.pending-answer {color: #ffa726; /* Orange for pending state */font-style: italic;}/* Blinking cursor for streaming answers */.pending-cursor {display: inline-block;color: #1d9bf0; /* Twitter blue */animation: blink 0.7s infinite;font-weight: bold;margin-left: 2px;font-style: normal; /* Override italic from pending-answer if nested */}@keyframes blink {0%, 100% { opacity: 0; }50% { opacity: 1; }}/* Styling for links generated from markdown in AI answers */.ai-generated-link {color: #1d9bf0; /* Twitter blue */text-decoration: underline;transition: color 0.2s ease;}.ai-generated-link:hover {color: #1a8cd8; /* Slightly darker blue on hover */text-decoration: underline;}/* Style for the new tooltip close button */.tooltip-close-button {/* Reuse general close button styles */background: none !important;border: none !important;color: #8899a6 !important; /* Match other control button colors */cursor: pointer !important;font-size: 20px !important; /* Slightly larger for easier tapping */line-height: 1 !important;padding: 4px 8px !important;margin-left: 8px !important; /* Space from other buttons */border-radius: 50% !important; /* Make it round */width: 28px !important; /* Explicit size */height: 28px !important; /* Explicit size */display: flex !important;align-items: center !important;justify-content: center !important;transition: all 0.2s !important;order: 3; /* Ensure it comes after pin and copy */}.tooltip-close-button:hover {background-color: rgba(255, 92, 92, 0.1) !important; /* Reddish background on hover */color: #ff5c5c !important; /* Red color on hover */}.tooltip-close-button:active {transform: scale(0.95) !important;}/* Adjust mobile close button specifically if needed */@media (max-width: 600px) {.tooltip-close-button {font-size: 22px !important; /* Even larger on mobile */width: 32px !important;height: 32px !important;}/* Ensure controls container accommodates button */.tooltip-controls {padding-right: 40px !important; /* Add padding to prevent overlap if button was absolute */}}/* --- Styling for Reasoning Dropdown within Conversation Turn --- */.conversation-turn .reasoning-dropdown {margin-top: 8px; /* Space above the dropdown */margin-bottom: 8px; /* Space below the dropdown, before the answer */border-radius: 4px;background-color: rgba(255, 255, 255, 0.02); /* Slightly different background from turn itself */border: 1px solid rgba(255, 255, 255, 0.08);}.conversation-turn .reasoning-toggle {display: flex;align-items: center;color: #b0bec5; /* Muted color for toggle text */cursor: pointer;font-weight: normal; /* Less prominent than main reasoning toggle */font-size: 0.85em;padding: 6px 8px;user-select: none;transition: background-color 0.2s;}.conversation-turn .reasoning-toggle:hover {background-color: rgba(255, 255, 255, 0.05);}.conversation-turn .reasoning-arrow {display: inline-block;margin-right: 4px;font-size: 0.9em;transition: transform 0.2s ease;}.conversation-turn .reasoning-content {max-height: 0;overflow: hidden;transition: max-height 0.3s ease-out, padding 0.3s ease-out;background-color: rgba(0, 0, 0, 0.1);border-radius: 0 0 4px 4px;padding: 0 8px; /* Horizontal padding only when collapsed */}.conversation-turn .reasoning-dropdown.expanded .reasoning-content {max-height: 200px; /* Adjust as needed */overflow-y: auto;padding: 8px; /* Full padding when expanded */}.conversation-turn .reasoning-dropdown.expanded .reasoning-arrow {transform: rotate(90deg);}.conversation-turn .reasoning-text {font-size: 0.85em; /* Smaller text for reasoning */line-height: 1.4;color: #ccc; /* Similar to main reasoning text */margin: 0;padding: 0; /* Padding is on the content container */}/* Ensure scrollbars look consistent */.conversation-turn .reasoning-content::-webkit-scrollbar {width: 5px;}.conversation-turn .reasoning-content::-webkit-scrollbar-track {background: rgba(255, 255, 255, 0.05);border-radius: 3px;}.conversation-turn .reasoning-content::-webkit-scrollbar-thumb {background: rgba(255, 255, 255, 0.2);border-radius: 3px;}.conversation-turn .reasoning-content::-webkit-scrollbar-thumb:hover {background: rgba(255, 255, 255, 0.3);}/* --- Styling for Image Upload in Follow-up --- */.tooltip-attach-image-button {background: none;border: none;color: #8899a6; /* Muted color, similar to other controls */font-size: 1.2em; /* Slightly larger for icon visibility */cursor: pointer;padding: 6px 8px; /* Adjust padding to align with input/button height */margin: 0 4px; /* Space around the icon */border-radius: 4px;transition: all 0.2s ease;align-self: center; /* Vertically align with input and Ask button */}.tooltip-attach-image-button:hover {background-color: rgba(29, 155, 240, 0.1);color: #1d9bf0;}.tooltip-follow-up-image-preview-container {margin-top: 10px;padding-top: 10px;border-top: 1px solid rgba(255, 255, 255, 0.1);display: flex; /* Changed to flex for easier alignment of preview and button */flex-direction: row; /* Lay out previews in a row */flex-wrap: wrap; /* Allow previews to wrap to the next line */gap: 10px; /* Spacing between preview items */align-items: flex-start;}.follow-up-image-preview-item {position: relative; /* For positioning the remove button */display: flex;flex-direction: column;align-items: center;border: 1px solid rgba(255, 255, 255, 0.2);border-radius: 6px;padding: 5px;background-color: rgba(255, 255, 255, 0.05);}.follow-up-image-preview-thumbnail {max-width: 80px; /* Smaller thumbnails for multiple previews */max-height: 80px;border-radius: 4px;object-fit: cover; /* Or contain, depending on desired look */margin-bottom: 5px; /* Space between image and potential future captions */}.follow-up-image-remove-btn {position: absolute;top: -5px;right: -5px;background-color: rgba(40, 40, 40, 0.8);color: white;border: 1px solid rgba(255,255,255,0.3);border-radius: 50%; /* Circular button */width: 20px;height: 20px;font-size: 12px;font-weight: bold;line-height: 18px; /* Adjust for vertical centering of X */text-align: center;cursor: pointer;padding: 0;transition: background-color 0.2s ease, transform 0.2s ease;}.follow-up-image-remove-btn:hover {background-color: rgba(255, 92, 92, 0.9);transform: scale(1.1);}/* Adjust custom question container to be flex for alignment */.tooltip-custom-question-container {/* ... existing styles ... */display: flex; /* Ensures items are in a row */align-items: center; /* Vertically aligns items in the middle */}.tooltip-custom-question-input {/* ... existing styles ... */margin-right: 0; /* Remove right margin if any, gap handles spacing */}/* --- Styling for Uploaded Image in Conversation History --- */.conversation-image-container {margin-top: 8px; /* Space above the image */margin-bottom: 8px; /* Space below the image, before reasoning/answer */display: flex; /* Use flex for multiple images */flex-wrap: wrap; /* Allow images to wrap */gap: 8px; /* Space between images */}.conversation-uploaded-image {max-width: 80%; /* Limit width to not dominate the tooltip */max-height: 120px; /* Slightly larger than preview, but still constrained */border-radius: 6px;border: 1px solid rgba(255, 255, 255, 0.2);object-fit: contain; /* Maintain aspect ratio */display: block; /* Ensure it takes its own line if needed */cursor: pointer; /* Indicate it can be clicked (e.g., for lightbox in future) */transition: transform 0.2s ease;}.conversation-uploaded-image:hover {transform: scale(1.02); /* Slight zoom on hover */}`;
  24. // Apply CSS
  25. GM_addStyle(STYLE);
  26. // Set menu HTML
  27. GM_setValue('menuHTML', MENU);
  28. // ----- helpers/browserStorage.js -----
  29. function browserGet(key, defaultValue = null) {
  30. try {
  31. return GM_getValue(key, defaultValue);
  32. } catch (error) {
  33. return defaultValue;
  34. }
  35. }
  36. function browserSet(key, value) {
  37. try {
  38. GM_setValue(key, value);
  39. } catch (error) {
  40. }
  41. }
  42. // ----- helpers/cache.js -----
  43. function updateCacheStatsUI() {
  44. const cachedCountEl = document.getElementById('cached-ratings-count');
  45. const whitelistedCountEl = document.getElementById('whitelisted-handles-count');
  46. const cachedCount = tweetCache.size;
  47. const wlCount = blacklistedHandles.length;
  48. if (cachedCountEl) cachedCountEl.textContent = cachedCount;
  49. if (whitelistedCountEl) whitelistedCountEl.textContent = wlCount;
  50. const statsBadge = document.getElementById("tweet-filter-stats-badge");
  51. if (statsBadge) statsBadge.innerHTML = `
  52. <span style="margin-right: 5px;">🧠</span>
  53. <span data-cached-count>${cachedCount} rated</span>
  54. <span data-pending-count> | ${pendingRequests} pending</span>
  55. ${wlCount > 0 ? `<span style="margin-left: 5px;"> | ${wlCount} whitelisted</span>` : ''}
  56. `;
  57. }
  58. // ----- backends/TweetCache.js -----
  59. function debounce(func, wait) {
  60. let timeout;
  61. return function executedFunction(...args) {
  62. const later = () => {
  63. clearTimeout(timeout);
  64. func.apply(this, args);
  65. };
  66. clearTimeout(timeout);
  67. timeout = setTimeout(later, wait);
  68. };
  69. };
  70. class TweetCache {
  71. static DEBOUNCE_DELAY = 1500;
  72. constructor() {
  73. this.cache = {};
  74. this.loadFromStorage();
  75. this.debouncedSaveToStorage = debounce(this.#saveToStorageInternal.bind(this), TweetCache.DEBOUNCE_DELAY);
  76. }
  77. loadFromStorage() {
  78. try {
  79. const storedCache = browserGet('tweetRatings', '{}');
  80. this.cache = JSON.parse(storedCache);
  81. for (const tweetId in this.cache) {
  82. this.cache[tweetId].fromStorage = true;
  83. }
  84. } catch (error) {
  85. this.cache = {};
  86. }
  87. }
  88. #saveToStorageInternal() {
  89. try {
  90. browserSet('tweetRatings', JSON.stringify(this.cache));
  91. updateCacheStatsUI();
  92. } catch (error) {
  93. }
  94. }
  95. get(tweetId) {
  96. return this.cache[tweetId] || null;
  97. }
  98. set(tweetId, rating, saveImmediately = true) {
  99. this.cache[tweetId] = {
  100. score: rating.score,
  101. fullContext: rating.fullContext || '',
  102. description: rating.description || '',
  103. reasoning: rating.reasoning || '',
  104. questions: rating.questions || [],
  105. lastAnswer: rating.lastAnswer || '',
  106. mediaUrls: rating.mediaUrls || [],
  107. timestamp: rating.timestamp || Date.now(),
  108. streaming: rating.streaming || false,
  109. blacklisted: rating.blacklisted || false,
  110. fromStorage: rating.fromStorage || false,
  111. metadata: {
  112. model: rating.metadata?.model || null,
  113. promptTokens: rating.metadata?.promptTokens || null,
  114. completionTokens: rating.metadata?.completionTokens || null,
  115. latency: rating.metadata?.latency || null,
  116. mediaInputs: rating.metadata?.mediaInputs || null,
  117. price: rating.metadata?.price || null
  118. },
  119. qaConversationHistory: rating.qaConversationHistory || []
  120. };
  121. if(!saveImmediately) {
  122. this.debouncedSaveToStorage();
  123. } else {
  124. this.#saveToStorageInternal();
  125. }
  126. }
  127. has(tweetId) {
  128. return this.cache[tweetId] !== undefined;
  129. }
  130. delete(tweetId, saveImmediately = true) {
  131. if (this.has(tweetId)) {
  132. delete this.cache[tweetId];
  133. this.debouncedSaveToStorage();
  134. }
  135. }
  136. clear(saveImmediately = false) {
  137. this.cache = {};
  138. if (saveImmediately) {
  139. this.#saveToStorageInternal();
  140. } else {
  141. this.debouncedSaveToStorage();
  142. }
  143. }
  144. get size() {
  145. return Object.keys(this.cache).length;
  146. }
  147. cleanup(saveImmediately = true) {
  148. const beforeCount = this.size;
  149. let deletedCount = 0;
  150. let streamingDeletedCount = 0;
  151. let undefinedScoreCount = 0;
  152. let missingQaHistoryCount = 0;
  153. for (const tweetId in this.cache) {
  154. const entry = this.cache[tweetId];
  155. let shouldDelete = false;
  156. if (entry.score === undefined || entry.score === null) {
  157. if (entry.streaming === true) {
  158. streamingDeletedCount++;
  159. } else {
  160. undefinedScoreCount++;
  161. }
  162. shouldDelete = true;
  163. }
  164. if (!entry.streaming && entry.score !== undefined && entry.score !== null && !entry.blacklisted &&
  165. (!entry.qaConversationHistory || !Array.isArray(entry.qaConversationHistory) || entry.qaConversationHistory.length < 3)) {
  166. missingQaHistoryCount++;
  167. shouldDelete = true;
  168. }
  169. if (shouldDelete) {
  170. delete this.cache[tweetId];
  171. deletedCount++;
  172. }
  173. }
  174. if (deletedCount > 0) {
  175. this.debouncedSaveToStorage();
  176. }
  177. return {
  178. beforeCount,
  179. afterCount: this.size,
  180. deletedCount,
  181. streamingDeletedCount,
  182. undefinedScoreCount,
  183. missingQaHistoryCount
  184. };
  185. }
  186. }
  187. const tweetCache = new TweetCache();
  188. // ----- backends/InstructionsHistory.js -----
  189. class InstructionsHistory {
  190. constructor() {
  191. if (InstructionsHistory.instance) {
  192. return InstructionsHistory.instance;
  193. }
  194. InstructionsHistory.instance = this;
  195. this.history = [];
  196. this.maxEntries = 10;
  197. this.loadFromStorage();
  198. }
  199. #hashString(str) {
  200. let hash = 0;
  201. for (let i = 0; i < str.length; i++) {
  202. const char = str.charCodeAt(i);
  203. hash = ((hash << 5) - hash) + char;
  204. hash = hash & hash;
  205. }
  206. return hash.toString(36);
  207. }
  208. loadFromStorage() {
  209. try {
  210. const stored = browserGet('instructionsHistory', '[]');
  211. this.history = JSON.parse(stored);
  212. if (!Array.isArray(this.history)) {
  213. throw new Error('Stored history is not an array');
  214. }
  215. this.history = this.history.map(entry => ({
  216. ...entry,
  217. hash: entry.hash || this.#hashString(entry.instructions)
  218. }));
  219. } catch (e) {
  220. this.history = [];
  221. }
  222. }
  223. #saveToStorage() {
  224. try {
  225. browserSet('instructionsHistory', JSON.stringify(this.history));
  226. } catch (e) {
  227. throw new Error('Failed to save instructions history');
  228. }
  229. }
  230. async add(instructions, summary) {
  231. try {
  232. if (!instructions?.trim() || !summary?.trim()) {
  233. throw new Error('Invalid instructions or summary');
  234. }
  235. const hash = this.#hashString(instructions.trim());
  236. const existingIndex = this.history.findIndex(entry => entry.hash === hash);
  237. if (existingIndex !== -1) {
  238. this.history[existingIndex].timestamp = Date.now();
  239. this.history[existingIndex].summary = summary;
  240. const entry = this.history.splice(existingIndex, 1)[0];
  241. this.history.unshift(entry);
  242. } else {
  243. this.history.unshift({
  244. instructions: instructions.trim(),
  245. summary: summary.trim(),
  246. timestamp: Date.now(),
  247. hash
  248. });
  249. if (this.history.length > this.maxEntries) {
  250. this.history = this.history.slice(0, this.maxEntries);
  251. }
  252. }
  253. this.#saveToStorage();
  254. return true;
  255. } catch (e) {
  256. return false;
  257. }
  258. }
  259. remove(index) {
  260. try {
  261. if (index < 0 || index >= this.history.length) {
  262. throw new Error('Invalid history index');
  263. }
  264. this.history.splice(index, 1);
  265. this.#saveToStorage();
  266. return true;
  267. } catch (e) {
  268. return false;
  269. }
  270. }
  271. getAll() {
  272. return [...this.history];
  273. }
  274. get(index) {
  275. try {
  276. if (index < 0 || index >= this.history.length) {
  277. return null;
  278. }
  279. return { ...this.history[index] };
  280. } catch (e) {
  281. return null;
  282. }
  283. }
  284. clear() {
  285. try {
  286. this.history = [];
  287. this.#saveToStorage();
  288. } catch (e) {
  289. throw new Error('Failed to clear instructions history');
  290. }
  291. }
  292. get size() {
  293. return this.history.length;
  294. }
  295. }
  296. // ----- backends/InstructionsManager.js -----
  297. class InstructionsManager {
  298. constructor() {
  299. if (InstructionsManager.instance) {
  300. return InstructionsManager.instance;
  301. }
  302. InstructionsManager.instance = this;
  303. this.history = new InstructionsHistory();
  304. this.currentInstructions = browserGet('userDefinedInstructions', '');
  305. }
  306. async saveInstructions(instructions) {
  307. if (!instructions?.trim()) {
  308. return { success: false, message: 'Instructions cannot be empty' };
  309. }
  310. instructions = instructions.trim();
  311. this.currentInstructions = instructions;
  312. browserSet('userDefinedInstructions', instructions);
  313. if (typeof USER_DEFINED_INSTRUCTIONS !== 'undefined') {
  314. USER_DEFINED_INSTRUCTIONS = instructions;
  315. }
  316. const summary = await getCustomInstructionsDescription(instructions);
  317. if (!summary.error) {
  318. await this.history.add(instructions, summary.content);
  319. }
  320. return {
  321. success: true,
  322. message: 'Scoring instructions saved! New tweets will use these instructions.',
  323. shouldClearCache: true
  324. };
  325. }
  326. getCurrentInstructions() {
  327. return this.currentInstructions;
  328. }
  329. getHistory() {
  330. return this.history.getAll();
  331. }
  332. removeFromHistory(index) {
  333. return this.history.remove(index);
  334. }
  335. clearHistory() {
  336. this.history.clear();
  337. }
  338. }
  339. const instructionsManager = new InstructionsManager();
  340. // ----- config.js -----
  341. const processedTweets = new Set();
  342. const adAuthorCache = new Set();
  343. const PROCESSING_DELAY_MS = 150;
  344. const API_CALL_DELAY_MS = 25;
  345. let USER_DEFINED_INSTRUCTIONS = instructionsManager.getCurrentInstructions() || 'Rate the tweet on a scale from 1 to 10 based on its clarity, insight, creativity, and overall quality.';
  346. let currentFilterThreshold = parseInt(browserGet('filterThreshold', '5'));
  347. let observedTargetNode = null;
  348. let lastAPICallTime = 0;
  349. let pendingRequests = 0;
  350. const MAX_RETRIES = 5;
  351. let availableModels = [];
  352. let listedModels = [];
  353. let selectedModel = browserGet('selectedModel', 'openai/gpt-4.1-nano');
  354. let selectedImageModel = browserGet('selectedImageModel', 'openai/gpt-4.1-nano');
  355. let showFreeModels = browserGet('showFreeModels', true);
  356. let providerSort = browserGet('providerSort', '');
  357. let blacklistedHandles = browserGet('blacklistedHandles', '').split('\n').filter(h => h.trim() !== '');
  358. let storedRatings = browserGet('tweetRatings', '{}');
  359. let threadHist = "";
  360. let enableImageDescriptions = browserGet('enableImageDescriptions', false);
  361. let enableStreaming = browserGet('enableStreaming', true);
  362. const REVIEW_SYSTEM_PROMPT=`
  363. You are **TweetFilter-AI**.
  364. When given a tweet, do these three steps **in order**:
  365. 1. **ANALYZE** - Judge how closely the tweet matches the user's instructions.
  366. 2. **SCORE** - Assign an integer from 0'10 (inclusive) based *only* on that alignment.
  367. 3. **ASK** - Write **exactly three** open-ended follow-up questions the user might ask next.
  368. Questions must be answerable from the tweet itself or general knowledge.
  369. Do **not** ask for information that requires unavailable context (e.g., the author's other tweets).
  370. **Important constraints**
  371. You do **not** have up-to-the-minute knowledge of current events.
  372. If a tweet makes a factual claim you cannot verify, **do not down-score it** for "fake news"; instead, evaluate it solely on the user's criteria and note any uncertainty in your analysis.
  373. Only down-score when the tweet contradicts widely-known, stable facts or directly violates the user's instructions.
  374. ⚠️ Output must match **exactly** the EXPECTED_RESPONSE_FORMAT" - no extra text, no missing tags - or the pipeline crashes.
  375. EXPECTED_RESPONSE_FORMAT: (begin with <ANALYSIS> and end with </FOLLOW_UP_QUESTIONS>)
  376. <ANALYSIS>
  377. (Your analysis goes here.)
  378. </ANALYSIS>
  379. <SCORE>
  380. SCORE_X
  381. </SCORE>
  382. <FOLLOW_UP_QUESTIONS>
  383. Q_1.
  384. Q_2.
  385. Q_3.
  386. </FOLLOW_UP_QUESTIONS>
  387. End of EXPECTED_RESPONSE_FORMAT
  388. `;
  389. const FOLLOW_UP_SYSTEM_PROMPT = `
  390. You are TweetFilter-AI, continuing a conversation about a tweet.
  391. The user has asked a follow-up question.
  392. Your entire conversation history up to this point is provided in the message list.
  393. Please provide an answer and then generate 3 new, relevant follow-up questions.
  394. Adhere strictly to the following response format:
  395. <ANSWER>
  396. (Your answer here)
  397. </ANSWER>
  398. <FOLLOW_UP_QUESTIONS>
  399. Q_1. (New Question 1 here)
  400. Q_2. (New Question 2 here)
  401. Q_3. (New Question 3 here)
  402. </FOLLOW_UP_QUESTIONS>
  403. `;
  404. let modelTemperature = parseFloat(browserGet('modelTemperature', '0.5'));
  405. let modelTopP = parseFloat(browserGet('modelTopP', '0.9'));
  406. let imageModelTemperature = parseFloat(browserGet('imageModelTemperature', '0.5'));
  407. let imageModelTopP = parseFloat(browserGet('imageModelTopP', '0.9'));
  408. let maxTokens = parseInt(browserGet('maxTokens', '0'));
  409. const TWEET_ARTICLE_SELECTOR = 'article[data-testid="tweet"]';
  410. const QUOTE_CONTAINER_SELECTOR = 'div[role="link"][tabindex="0"]';
  411. const USER_HANDLE_SELECTOR = 'div[data-testid="User-Name"] a[role="link"]';
  412. const TWEET_TEXT_SELECTOR = 'div[data-testid="tweetText"]';
  413. const MEDIA_IMG_SELECTOR = 'div[data-testid="tweetPhoto"] img, img[src*="pbs.twimg.com/media"]';
  414. const MEDIA_VIDEO_SELECTOR = 'video[poster*="pbs.twimg.com"], video';
  415. const PERMALINK_SELECTOR = 'a[href*="/status/"] time';
  416. function modelSupportsImages(modelId) {
  417. if (!availableModels || availableModels.length === 0) {
  418. return false;
  419. }
  420. const model = availableModels.find(m => m.slug === modelId);
  421. if (!model) {
  422. return false;
  423. }
  424. return model.input_modalities &&
  425. model.input_modalities.includes('image');
  426. }
  427. // ----- domScraper.js -----
  428. function getElementText(elements) {
  429. if (!elements) return '';
  430. const elementList = elements instanceof NodeList ? Array.from(elements) : [elements];
  431. for (const element of elementList) {
  432. const text = element?.textContent?.trim();
  433. if (text) return text;
  434. }
  435. return '';
  436. }
  437. function getTweetID(tweetArticle) {
  438. const timeEl = tweetArticle.querySelector(PERMALINK_SELECTOR);
  439. let tweetId = timeEl?.parentElement?.href;
  440. if (tweetId && tweetId.includes('/status/')) {
  441. const match = tweetId.match(/\/status\/(\d+)/);
  442. if (match && match[1]) {
  443. return match[1];
  444. }
  445. return tweetId.substring(tweetId.indexOf('/status/') + 1);
  446. }
  447. return `tweet-${Math.random().toString(36).substring(2, 15)}-${Date.now()}`;
  448. }
  449. function getUserHandles(tweetArticle) {
  450. let handles = [];
  451. const handleElement = tweetArticle.querySelector(USER_HANDLE_SELECTOR);
  452. if (handleElement) {
  453. const href = handleElement.getAttribute('href');
  454. if (href && href.startsWith('/')) {
  455. handles.push(href.slice(1));
  456. }
  457. }
  458. if (handles.length > 0) {
  459. const quoteContainer = tweetArticle.querySelector('div[role="link"][tabindex="0"]');
  460. if (quoteContainer) {
  461. const userAvatarDiv = quoteContainer.querySelector('div[data-testid^="UserAvatar-Container-"]');
  462. if (userAvatarDiv) {
  463. const testId = userAvatarDiv.getAttribute('data-testid');
  464. const lastDashIndex = testId.lastIndexOf('-');
  465. if (lastDashIndex >= 0 && lastDashIndex < testId.length - 1) {
  466. const quotedHandle = testId.substring(lastDashIndex + 1);
  467. if (quotedHandle && quotedHandle !== handles[0]) {
  468. handles.push(quotedHandle);
  469. }
  470. }
  471. const quotedLink = quoteContainer.querySelector('a[href*="/status/"]');
  472. if (quotedLink) {
  473. const href = quotedLink.getAttribute('href');
  474. const match = href.match(/^\/([^/]+)\/status\/\d+/);
  475. if (match && match[1] && match[1] !== handles[0]) {
  476. handles.push(match[1]);
  477. }
  478. }
  479. }
  480. }
  481. }
  482. return handles.length > 0 ? handles : [''];
  483. }
  484. async function extractMediaLinks(scopeElement) {
  485. if (!scopeElement) return [];
  486. const mediaLinks = new Set();
  487. const imgSelector = `${MEDIA_IMG_SELECTOR}, [data-testid="tweetPhoto"] img, img[src*="pbs.twimg.com/media"]`;
  488. const videoSelector = `${MEDIA_VIDEO_SELECTOR}, video[poster*="pbs.twimg.com"], video`;
  489. const combinedSelector = `${imgSelector}, ${videoSelector}`;
  490. let mediaElements = scopeElement.querySelectorAll(combinedSelector);
  491. const RETRY_DELAY = 100;
  492. let retries = 0;
  493. while (mediaElements.length === 0 && retries < MAX_RETRIES) {
  494. retries++;
  495. await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
  496. mediaElements = scopeElement.querySelectorAll(combinedSelector);
  497. }
  498. if (mediaElements.length === 0 && scopeElement.matches(QUOTE_CONTAINER_SELECTOR)) {
  499. mediaElements = scopeElement.querySelectorAll('img[src*="pbs.twimg.com"], video[poster*="pbs.twimg.com"]');
  500. }
  501. mediaElements.forEach(mediaEl => {
  502. const sourceUrl = mediaEl.tagName === 'IMG' ? mediaEl.src : mediaEl.poster;
  503. if (!sourceUrl ||
  504. !(sourceUrl.includes('pbs.twimg.com/')) ||
  505. sourceUrl.includes('profile_images')) {
  506. return;
  507. }
  508. try {
  509. const url = new URL(sourceUrl);
  510. const name = url.searchParams.get('name');
  511. let finalUrl = sourceUrl;
  512. if (name && name !== 'orig') {
  513. finalUrl = sourceUrl.replace(`name=${name}`, 'name=small');
  514. }
  515. mediaLinks.add(finalUrl);
  516. } catch (error) {
  517. mediaLinks.add(sourceUrl);
  518. }
  519. });
  520. return Array.from(mediaLinks);
  521. }
  522. function isOriginalTweet(tweetArticle) {
  523. let sibling = tweetArticle.nextElementSibling;
  524. while (sibling) {
  525. if (sibling.matches && sibling.matches('div[data-testid="inline_reply_offscreen"]')) {
  526. return true;
  527. }
  528. sibling = sibling.nextElementSibling;
  529. }
  530. return false;
  531. }
  532. function handleMutations(mutationsList) {
  533. let tweetsAdded = false;
  534. let needsCleanup = false;
  535. const shouldSkipProcessing = (element) => {
  536. if (!element) return true;
  537. if (element.dataset?.filtered === 'true' || element.dataset?.isAd === 'true') {
  538. return true;
  539. }
  540. const cell = element.closest('div[data-testid="cellInnerDiv"]');
  541. if (cell?.dataset?.filtered === 'true' || cell?.dataset?.isAd === 'true') {
  542. return true;
  543. }
  544. if (isAd(element)) {
  545. if (cell) {
  546. cell.dataset.isAd = 'true';
  547. cell.classList.add('tweet-filtered');
  548. }
  549. element.dataset.isAd = 'true';
  550. return true;
  551. }
  552. const tweetId = getTweetID(element);
  553. if (processedTweets.has(tweetId)) {
  554. const indicator = ScoreIndicatorRegistry.get(tweetId);
  555. if (indicator && indicator.status !== 'error') {
  556. return true;
  557. }
  558. }
  559. return false;
  560. };
  561. for (const mutation of mutationsList) {
  562. if (mutation.type === 'childList') {
  563. if (mutation.addedNodes.length > 0) {
  564. mutation.addedNodes.forEach(node => {
  565. if (node.nodeType === Node.ELEMENT_NODE) {
  566. let conversationTimeline = null;
  567. if (node.matches && node.matches('div[aria-label^="Timeline: Conversation"]')) {
  568. conversationTimeline = node;
  569. } else if (node.querySelector) {
  570. conversationTimeline = node.querySelector('div[aria-label^="Timeline: Conversation"]');
  571. }
  572. if (conversationTimeline) {
  573. setTimeout(handleThreads, 50);
  574. }
  575. if (node.matches && node.matches(TWEET_ARTICLE_SELECTOR)) {
  576. if (!shouldSkipProcessing(node)) {
  577. scheduleTweetProcessing(node);
  578. tweetsAdded = true;
  579. }
  580. }
  581. else if (node.querySelector) {
  582. const tweetsInside = node.querySelectorAll(TWEET_ARTICLE_SELECTOR);
  583. tweetsInside.forEach(tweet => {
  584. if (!shouldSkipProcessing(tweet)) {
  585. scheduleTweetProcessing(tweet);
  586. tweetsAdded = true;
  587. }
  588. });
  589. }
  590. }
  591. });
  592. }
  593. if (mutation.removedNodes.length > 0) {
  594. mutation.removedNodes.forEach(node => {
  595. if (node.nodeType === Node.ELEMENT_NODE) {
  596. if (node.dataset?.filtered === 'true' || node.dataset?.isAd === 'true') {
  597. return;
  598. }
  599. if (node.matches && node.matches(TWEET_ARTICLE_SELECTOR)) {
  600. const tweetId = getTweetID(node);
  601. if (tweetId) {
  602. ScoreIndicatorRegistry.get(tweetId)?.destroy();
  603. needsCleanup = true;
  604. }
  605. }
  606. else if (node.querySelectorAll) {
  607. const removedTweets = node.querySelectorAll(TWEET_ARTICLE_SELECTOR);
  608. removedTweets.forEach(tweet => {
  609. if (tweet.dataset?.filtered === 'true' || tweet.dataset?.isAd === 'true') {
  610. return;
  611. }
  612. const tweetId = getTweetID(tweet);
  613. if (tweetId) {
  614. ScoreIndicatorRegistry.get(tweetId)?.destroy();
  615. needsCleanup = true;
  616. }
  617. });
  618. }
  619. }
  620. });
  621. }
  622. }
  623. }
  624. if (tweetsAdded) {
  625. setTimeout(() => {
  626. applyFilteringToAll();
  627. }, 100);
  628. }
  629. if (needsCleanup) {
  630. ScoreIndicatorRegistry.cleanupOrphaned();
  631. }
  632. }
  633. function isAd(tweetArticle) {
  634. if (!tweetArticle) return false;
  635. const spans = tweetArticle.querySelectorAll('div[dir="ltr"] span');
  636. for (const span of spans) {
  637. if (span.textContent.trim() === 'Ad' && !span.children.length) {
  638. return true;
  639. }
  640. }
  641. return false;
  642. }
  643. // ----- ui/utils.js -----
  644. function isMobileDevice() {
  645. return (window.innerWidth <= 600 ||
  646. /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
  647. }
  648. function showStatus(message, type = 'info') {
  649. const indicator = document.getElementById('status-indicator');
  650. if (!indicator) {
  651. return;
  652. }
  653. indicator.textContent = message;
  654. indicator.className = 'active ' + type;
  655. setTimeout(() => { indicator.classList.remove('active', type); }, 3000);
  656. }
  657. function resizeImage(file, maxDimPx) {
  658. return new Promise((resolve, reject) => {
  659. const reader = new FileReader();
  660. reader.onload = (event) => {
  661. const img = new Image();
  662. img.onload = () => {
  663. let { width, height } = img;
  664. let newWidth, newHeight;
  665. if (width > height) {
  666. if (width > maxDimPx) {
  667. newWidth = maxDimPx;
  668. newHeight = height * (maxDimPx / width);
  669. } else {
  670. newWidth = width;
  671. newHeight = height;
  672. }
  673. } else {
  674. if (height > maxDimPx) {
  675. newHeight = maxDimPx;
  676. newWidth = width * (maxDimPx / height);
  677. } else {
  678. newWidth = width;
  679. newHeight = height;
  680. }
  681. }
  682. const canvas = document.createElement('canvas');
  683. canvas.width = newWidth;
  684. canvas.height = newHeight;
  685. const ctx = canvas.getContext('2d');
  686. ctx.drawImage(img, 0, 0, newWidth, newHeight);
  687. const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
  688. resolve(dataUrl);
  689. };
  690. img.onerror = (error) => {
  691. reject(new Error("Could not load image for resizing."));
  692. };
  693. img.src = event.target.result;
  694. };
  695. reader.onerror = (error) => {
  696. reject(new Error("Could not read file."));
  697. };
  698. reader.readAsDataURL(file);
  699. });
  700. }
  701. // ----- ui/InstructionsUI.js -----
  702. async function saveInstructions() {
  703. const instructionsTextarea = document.getElementById('user-instructions');
  704. const result = await instructionsManager.saveInstructions(instructionsTextarea.value);
  705. showStatus(result.message);
  706. if (result.success && result.shouldClearCache) {
  707. if (isMobileDevice() || confirm('Do you want to clear the rating cache to apply these instructions to all tweets?')) {
  708. clearTweetRatingsAndRefreshUI();
  709. }
  710. }
  711. if (result.success) {
  712. refreshInstructionsHistory();
  713. }
  714. }
  715. function refreshInstructionsHistory() {
  716. const listElement = document.getElementById('instructions-list');
  717. if (!listElement) return;
  718. const history = instructionsManager.getHistory();
  719. listElement.innerHTML = '';
  720. if (history.length === 0) {
  721. const emptyMsg = document.createElement('div');
  722. emptyMsg.style.cssText = 'padding: 8px; opacity: 0.7; font-style: italic;';
  723. emptyMsg.textContent = 'No saved instructions yet';
  724. listElement.appendChild(emptyMsg);
  725. return;
  726. }
  727. history.forEach((entry, index) => {
  728. const item = createHistoryItem(entry, index);
  729. listElement.appendChild(item);
  730. });
  731. }
  732. function createHistoryItem(entry, index) {
  733. const item = document.createElement('div');
  734. item.className = 'instruction-item';
  735. item.dataset.index = index;
  736. const text = document.createElement('div');
  737. text.className = 'instruction-text';
  738. text.textContent = entry.summary;
  739. text.title = entry.instructions;
  740. item.appendChild(text);
  741. const buttons = document.createElement('div');
  742. buttons.className = 'instruction-buttons';
  743. const useBtn = document.createElement('button');
  744. useBtn.className = 'use-instruction';
  745. useBtn.textContent = 'Use';
  746. useBtn.title = 'Use these instructions';
  747. useBtn.onclick = () => useInstructions(entry.instructions);
  748. buttons.appendChild(useBtn);
  749. const removeBtn = document.createElement('button');
  750. removeBtn.className = 'remove-instruction';
  751. removeBtn.textContent = '×';
  752. removeBtn.title = 'Remove from history';
  753. removeBtn.onclick = () => removeInstructions(index);
  754. buttons.appendChild(removeBtn);
  755. item.appendChild(buttons);
  756. return item;
  757. }
  758. function useInstructions(instructions) {
  759. const textarea = document.getElementById('user-instructions');
  760. if (textarea) {
  761. textarea.value = instructions;
  762. saveInstructions();
  763. }
  764. }
  765. function removeInstructions(index) {
  766. if (instructionsManager.removeFromHistory(index)) {
  767. refreshInstructionsHistory();
  768. showStatus('Instructions removed from history');
  769. } else {
  770. showStatus('Error removing instructions');
  771. }
  772. }
  773. function clearInstructionsHistory() {
  774. if (isMobileDevice() || confirm('Are you sure you want to clear all instruction history?')) {
  775. instructionsManager.clearHistory();
  776. refreshInstructionsHistory();
  777. showStatus('Instructions history cleared');
  778. }
  779. }
  780. // ----- ui/ScoreIndicator.js -----
  781. class ScoreIndicator {
  782. constructor(tweetArticle) {
  783. if (!tweetArticle || !tweetArticle.nodeType || tweetArticle.nodeType !== Node.ELEMENT_NODE) {
  784. throw new Error("ScoreIndicator requires a valid tweet article DOM element.");
  785. }
  786. this.tweetArticle = tweetArticle;
  787. this.tweetId = getTweetID(this.tweetArticle);
  788. this.indicatorElement = null;
  789. this.tooltipElement = null;
  790. this.tooltipControls = null;
  791. this.pinButton = null;
  792. this.copyButton = null;
  793. this.tooltipCloseButton = null;
  794. this.reasoningDropdown = null;
  795. this.reasoningToggle = null;
  796. this.reasoningArrow = null;
  797. this.reasoningContent = null;
  798. this.reasoningTextElement = null;
  799. this.descriptionElement = null;
  800. this.scoreTextElement = null;
  801. this.followUpQuestionsTextElement = null;
  802. this.scrollButton = null;
  803. this.metadataElement = null;
  804. this.conversationContainerElement = null;
  805. this.followUpQuestionsElement = null;
  806. this.customQuestionContainer = null;
  807. this.customQuestionInput = null;
  808. this.customQuestionButton = null;
  809. this.attachImageButton = null;
  810. this.followUpImageContainer = null;
  811. this.followUpImageInput = null;
  812. this.followUpImagePreview = null;
  813. this.followUpRemoveImageButton = null;
  814. this.uploadedImageDataUrls = [];
  815. this.status = 'pending';
  816. this.score = null;
  817. this.description = '';
  818. this.reasoning = '';
  819. this.metadata = null;
  820. this.conversationHistory = [];
  821. this.questions = [];
  822. this.isPinned = false;
  823. this.isVisible = false;
  824. this.autoScroll = true;
  825. this.userInitiatedScroll = false;
  826. this.uploadedImageDataUrls = [];
  827. this.qaConversationHistory = [];
  828. try {
  829. this._createElements(tweetArticle);
  830. this._addEventListeners();
  831. ScoreIndicatorRegistry.add(this.tweetId, this);
  832. this.updateDatasetAttributes(tweetArticle);
  833. } catch (error) {
  834. this.destroy();
  835. throw error;
  836. }
  837. }
  838. _createElements(initialTweetArticle) {
  839. this.indicatorElement = document.createElement('div');
  840. this.indicatorElement.className = 'score-indicator';
  841. this.indicatorElement.dataset.tweetId = this.tweetId;
  842. const currentPosition = window.getComputedStyle(initialTweetArticle).position;
  843. if (currentPosition !== 'relative' && currentPosition !== 'absolute' && currentPosition !== 'fixed' && currentPosition !== 'sticky') {
  844. initialTweetArticle.style.position = 'relative';
  845. }
  846. initialTweetArticle.appendChild(this.indicatorElement);
  847. this.tooltipElement = document.createElement('div');
  848. this.tooltipElement.className = 'score-description';
  849. this.tooltipElement.style.display = 'none';
  850. this.tooltipElement.dataset.tweetId = this.tweetId;
  851. this.tooltipElement.dataset.autoScroll = this.autoScroll ? 'true' : 'false';
  852. this.tooltipControls = document.createElement('div');
  853. this.tooltipControls.className = 'tooltip-controls';
  854. this.tooltipCloseButton = document.createElement('button');
  855. this.tooltipCloseButton.className = 'close-button tooltip-close-button';
  856. this.tooltipCloseButton.innerHTML = '×';
  857. this.tooltipCloseButton.title = 'Close tooltip';
  858. this.pinButton = document.createElement('button');
  859. this.pinButton.className = 'tooltip-pin-button';
  860. this.pinButton.innerHTML = '📌';
  861. this.pinButton.title = 'Pin tooltip (prevents auto-closing)';
  862. this.copyButton = document.createElement('button');
  863. this.copyButton.className = 'tooltip-copy-button';
  864. this.copyButton.innerHTML = '📋';
  865. this.copyButton.title = 'Copy content to clipboard';
  866. this.tooltipControls.appendChild(this.pinButton);
  867. this.tooltipControls.appendChild(this.copyButton);
  868. this.tooltipControls.appendChild(this.tooltipCloseButton);
  869. this.tooltipElement.appendChild(this.tooltipControls);
  870. this.reasoningDropdown = document.createElement('div');
  871. this.reasoningDropdown.className = 'reasoning-dropdown';
  872. this.reasoningDropdown.style.display = 'none';
  873. this.reasoningToggle = document.createElement('div');
  874. this.reasoningToggle.className = 'reasoning-toggle';
  875. this.reasoningArrow = document.createElement('span');
  876. this.reasoningArrow.className = 'reasoning-arrow';
  877. this.reasoningArrow.textContent = '▶';
  878. this.reasoningToggle.appendChild(this.reasoningArrow);
  879. this.reasoningToggle.appendChild(document.createTextNode(' Show Reasoning Trace'));
  880. this.reasoningContent = document.createElement('div');
  881. this.reasoningContent.className = 'reasoning-content';
  882. this.reasoningTextElement = document.createElement('p');
  883. this.reasoningTextElement.className = 'reasoning-text';
  884. this.reasoningContent.appendChild(this.reasoningTextElement);
  885. this.reasoningDropdown.appendChild(this.reasoningToggle);
  886. this.reasoningDropdown.appendChild(this.reasoningContent);
  887. this.tooltipElement.appendChild(this.reasoningDropdown);
  888. this.descriptionElement = document.createElement('div');
  889. this.descriptionElement.className = 'description-text';
  890. this.tooltipElement.appendChild(this.descriptionElement);
  891. this.scoreTextElement = document.createElement('div');
  892. this.scoreTextElement.className = 'score-text-from-description';
  893. this.scoreTextElement.style.display = 'none';
  894. this.tooltipElement.appendChild(this.scoreTextElement);
  895. this.followUpQuestionsTextElement = document.createElement('div');
  896. this.followUpQuestionsTextElement.className = 'follow-up-questions-text-from-description';
  897. this.followUpQuestionsTextElement.style.display = 'none';
  898. this.tooltipElement.appendChild(this.followUpQuestionsTextElement);
  899. this.conversationContainerElement = document.createElement('div');
  900. this.conversationContainerElement.className = 'tooltip-conversation-history';
  901. this.tooltipElement.appendChild(this.conversationContainerElement);
  902. this.followUpQuestionsElement = document.createElement('div');
  903. this.followUpQuestionsElement.className = 'tooltip-follow-up-questions';
  904. this.followUpQuestionsElement.style.display = 'none';
  905. this.tooltipElement.appendChild(this.followUpQuestionsElement);
  906. this.customQuestionContainer = document.createElement('div');
  907. this.customQuestionContainer.className = 'tooltip-custom-question-container';
  908. this.customQuestionInput = document.createElement('textarea');
  909. this.customQuestionInput.placeholder = 'Ask your own question...';
  910. this.customQuestionInput.className = 'tooltip-custom-question-input';
  911. this.customQuestionInput.rows = 1;
  912. this.customQuestionInput.addEventListener('input', function() {
  913. this.style.height = 'auto';
  914. this.style.height = (this.scrollHeight) + 'px';
  915. });
  916. const currentSelectedModel = browserGet('selectedModel', 'openai/gpt-4.1-nano');
  917. const supportsImages = typeof modelSupportsImages === 'function' && modelSupportsImages(currentSelectedModel);
  918. if (supportsImages) {
  919. this.attachImageButton = document.createElement('button');
  920. this.attachImageButton.textContent = '📎';
  921. this.attachImageButton.className = 'tooltip-attach-image-button';
  922. this.attachImageButton.title = 'Attach image(s)';
  923. this.followUpImageInput = document.createElement('input');
  924. this.followUpImageInput.type = 'file';
  925. this.followUpImageInput.accept = 'image/' + '*';
  926. this.followUpImageInput.multiple = true;
  927. this.followUpImageInput.style.display = 'none';
  928. }
  929. this.customQuestionButton = document.createElement('button');
  930. this.customQuestionButton.textContent = 'Ask';
  931. this.customQuestionButton.className = 'tooltip-custom-question-button';
  932. this.customQuestionContainer.appendChild(this.customQuestionInput);
  933. if (this.attachImageButton) {
  934. this.customQuestionContainer.appendChild(this.attachImageButton);
  935. if (this.followUpImageInput) {
  936. this.customQuestionContainer.appendChild(this.followUpImageInput);
  937. }
  938. }
  939. this.customQuestionContainer.appendChild(this.customQuestionButton);
  940. this.tooltipElement.appendChild(this.customQuestionContainer);
  941. if (supportsImages) {
  942. this.followUpImageContainer = document.createElement('div');
  943. this.followUpImageContainer.className = 'tooltip-follow-up-image-preview-container';
  944. this.tooltipElement.insertBefore(this.followUpImageContainer, this.metadataElement);
  945. }
  946. this.metadataElement = document.createElement('div');
  947. this.metadataElement.className = 'tooltip-metadata';
  948. this.metadataElement.style.display = 'none';
  949. this.tooltipElement.appendChild(this.metadataElement);
  950. this.scrollButton = document.createElement('div');
  951. this.scrollButton.className = 'scroll-to-bottom-button';
  952. this.scrollButton.innerHTML = '⬇ Scroll to bottom';
  953. this.scrollButton.style.display = 'none';
  954. this.tooltipElement.appendChild(this.scrollButton);
  955. const bottomSpacer = document.createElement('div');
  956. bottomSpacer.className = 'tooltip-bottom-spacer';
  957. this.tooltipElement.appendChild(bottomSpacer);
  958. document.body.appendChild(this.tooltipElement);
  959. if (isMobileDevice()) {
  960. this.indicatorElement?.classList.add('mobile-indicator');
  961. this.tooltipElement?.classList.add('mobile-tooltip');
  962. }
  963. this._updateIndicatorUI();
  964. this._updateTooltipUI();
  965. }
  966. _addEventListeners() {
  967. if (!this.indicatorElement || !this.tooltipElement) return;
  968. this.indicatorElement.addEventListener('mouseenter', this._handleMouseEnter.bind(this));
  969. this.indicatorElement.addEventListener('mouseleave', this._handleMouseLeave.bind(this));
  970. this.indicatorElement.addEventListener('click', this._handleIndicatorClick.bind(this));
  971. this.tooltipElement.addEventListener('mouseenter', this._handleTooltipMouseEnter.bind(this));
  972. this.tooltipElement.addEventListener('mouseleave', this._handleTooltipMouseLeave.bind(this));
  973. this.tooltipElement.addEventListener('scroll', this._handleTooltipScroll.bind(this));
  974. this.pinButton?.addEventListener('click', this._handlePinClick.bind(this));
  975. this.copyButton?.addEventListener('click', this._handleCopyClick.bind(this));
  976. this.tooltipCloseButton?.addEventListener('click', this._handleCloseClick.bind(this));
  977. this.reasoningToggle?.addEventListener('click', this._handleReasoningToggleClick.bind(this));
  978. this.scrollButton?.addEventListener('click', this._handleScrollButtonClick.bind(this));
  979. this.followUpQuestionsElement?.addEventListener('click', this._handleFollowUpQuestionClick.bind(this));
  980. this.customQuestionButton?.addEventListener('click', this._handleCustomQuestionClick.bind(this));
  981. this.customQuestionInput?.addEventListener('keydown', (event) => {
  982. if (event.key === 'Enter') {
  983. event.preventDefault();
  984. this._handleCustomQuestionClick();
  985. }
  986. });
  987. if (this.attachImageButton && this.followUpImageInput) {
  988. this.attachImageButton.addEventListener('click', () => this.followUpImageInput.click());
  989. this.followUpImageInput.addEventListener('change', this._handleFollowUpImageSelect.bind(this));
  990. }
  991. }
  992. updateDatasetAttributes(currentTweetArticle) {
  993. const article = currentTweetArticle || this.findCurrentArticleElement();
  994. if (!article) {
  995. return;
  996. }
  997. article.dataset.ratingStatus = this.status;
  998. article.dataset.sloppinessScore = this.score !== null ? String(this.score) : '';
  999. article.dataset.ratingDescription = this.description;
  1000. article.dataset.ratingReasoning = this.reasoning;
  1001. article.dataset.blacklisted = String(this.status === 'blacklisted');
  1002. article.dataset.cachedRating = String(this.status === 'cached');
  1003. }
  1004. _updateIndicatorUI() {
  1005. if (!this.indicatorElement) return;
  1006. const classList = this.indicatorElement.classList;
  1007. classList.remove(
  1008. 'pending-rating', 'rated-rating', 'error-rating',
  1009. 'cached-rating', 'blacklisted-rating', 'streaming-rating'
  1010. );
  1011. let indicatorText = '';
  1012. let indicatorClass = '';
  1013. switch (this.status) {
  1014. case 'pending':
  1015. indicatorClass = 'pending-rating';
  1016. indicatorText = '⏳';
  1017. break;
  1018. case 'streaming':
  1019. indicatorClass = 'streaming-rating';
  1020. indicatorText = (this.score !== null && this.score !== undefined) ? String(this.score) : '🔄';
  1021. break;
  1022. case 'error':
  1023. indicatorClass = 'error-rating';
  1024. indicatorText = '⚠️';
  1025. break;
  1026. case 'cached':
  1027. indicatorClass = 'cached-rating';
  1028. indicatorText = String(this.score);
  1029. break;
  1030. case 'blacklisted':
  1031. indicatorClass = 'blacklisted-rating';
  1032. indicatorText = String(this.score);
  1033. break;
  1034. case 'rated':
  1035. default:
  1036. indicatorClass = 'rated-rating';
  1037. indicatorText = String(this.score);
  1038. break;
  1039. }
  1040. if (indicatorClass) {
  1041. classList.add(indicatorClass);
  1042. }
  1043. this.indicatorElement.textContent = indicatorText;
  1044. }
  1045. _updateTooltipUI() {
  1046. if (!this.tooltipElement || !this.descriptionElement || !this.scoreTextElement || !this.followUpQuestionsTextElement || !this.reasoningTextElement || !this.reasoningDropdown || !this.conversationContainerElement || !this.followUpQuestionsElement || !this.metadataElement) {
  1047. return;
  1048. }
  1049. const wasNearBottom = this.tooltipElement.scrollHeight - this.tooltipElement.scrollTop - this.tooltipElement.clientHeight < (isMobileDevice() ? 40 : 55);
  1050. const previousScrollTop = this.tooltipElement.scrollTop;
  1051. const previousScrollHeight = this.tooltipElement.scrollHeight;
  1052. const fullDescription = this.description || "";
  1053. const analysisMatch = fullDescription.match(/<ANALYSIS>([^<]+)<\/ANALYSIS>/);
  1054. const scoreMatch = fullDescription.match(/<SCORE>([^<]+)<\/SCORE>/);
  1055. const questionsMatch = fullDescription.match(/<FOLLOW_UP_QUESTIONS>([^<]+)<\/FOLLOW_UP_QUESTIONS>/);
  1056. let analysisContent = "";
  1057. let scoreContent = "";
  1058. let questionsContent = "";
  1059. if (analysisMatch && analysisMatch[1] !== undefined) {
  1060. analysisContent = analysisMatch[1].trim();
  1061. } else if (!scoreMatch && !questionsMatch) {
  1062. analysisContent = fullDescription;
  1063. } else {
  1064. analysisContent = "*Waiting for analysis...*";
  1065. }
  1066. if (scoreMatch && scoreMatch[1] !== undefined) {
  1067. scoreContent = scoreMatch[1].trim();
  1068. }
  1069. if (questionsMatch && questionsMatch[1] !== undefined) {
  1070. questionsContent = questionsMatch[1].trim();
  1071. }
  1072. let contentChanged = false;
  1073. const formattedAnalysis = formatTooltipDescription(analysisContent).description;
  1074. if (this.descriptionElement.innerHTML !== formattedAnalysis) {
  1075. this.descriptionElement.innerHTML = formattedAnalysis;
  1076. contentChanged = true;
  1077. }
  1078. if (scoreContent) {
  1079. const formattedScoreText = scoreContent
  1080. .replace(/</g, '&lt;').replace(/>/g, '&gt;') // Basic escaping
  1081. .replace(/SCORE_(\d+)/g, '<span class="score-highlight">SCORE: $1</span>') // Apply highlighting
  1082. .replace(/\n/g, '<br>');
  1083. if (this.scoreTextElement.innerHTML !== formattedScoreText) {
  1084. this.scoreTextElement.innerHTML = formattedScoreText;
  1085. contentChanged = true;
  1086. }
  1087. this.scoreTextElement.style.display = 'block';
  1088. } else {
  1089. if (this.scoreTextElement.style.display !== 'none') {
  1090. this.scoreTextElement.style.display = 'none';
  1091. this.scoreTextElement.innerHTML = '';
  1092. contentChanged = true;
  1093. }
  1094. }
  1095. if (questionsContent) {
  1096. const formattedQuestionsText = questionsContent.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
  1097. if (this.followUpQuestionsTextElement.innerHTML !== formattedQuestionsText) {
  1098. this.followUpQuestionsTextElement.innerHTML = formattedQuestionsText;
  1099. }
  1100. } else {
  1101. if (this.followUpQuestionsTextElement.innerHTML !== '') {
  1102. this.followUpQuestionsTextElement.innerHTML = '';
  1103. }
  1104. }
  1105. this.followUpQuestionsTextElement.style.display = 'none';
  1106. const formattedReasoning = formatTooltipDescription("", this.reasoning).reasoning;
  1107. if (this.reasoningTextElement.innerHTML !== formattedReasoning) {
  1108. this.reasoningTextElement.innerHTML = formattedReasoning;
  1109. contentChanged = true;
  1110. }
  1111. const showReasoning = !!formattedReasoning;
  1112. if ((this.reasoningDropdown.style.display === 'none') === showReasoning) {
  1113. this.reasoningDropdown.style.display = showReasoning ? 'block' : 'none';
  1114. contentChanged = true;
  1115. }
  1116. const renderedHistory = this._renderConversationHistory();
  1117. if (this.conversationContainerElement.innerHTML !== renderedHistory) {
  1118. this.conversationContainerElement.innerHTML = renderedHistory;
  1119. this.conversationContainerElement.style.display = this.conversationHistory.length > 0 ? 'block' : 'none';
  1120. contentChanged = true;
  1121. }
  1122. let questionsButtonsChanged = false;
  1123. if (this.followUpQuestionsElement.children.length !== (this.questions?.length || 0)) {
  1124. questionsButtonsChanged = true;
  1125. } else {
  1126. this.questions?.forEach((q, i) => {
  1127. const button = this.followUpQuestionsElement.children[i];
  1128. if (!button || button.dataset.questionText !== q) {
  1129. questionsButtonsChanged = true;
  1130. }
  1131. });
  1132. }
  1133. if (questionsButtonsChanged) {
  1134. this.followUpQuestionsElement.innerHTML = '';
  1135. if (this.questions && this.questions.length > 0) {
  1136. this.questions.forEach((question, index) => {
  1137. const questionButton = document.createElement('button');
  1138. questionButton.className = 'follow-up-question-button';
  1139. questionButton.textContent = `🤔 ${question}`;
  1140. questionButton.dataset.questionIndex = index;
  1141. questionButton.dataset.questionText = question;
  1142. this.followUpQuestionsElement.appendChild(questionButton);
  1143. });
  1144. this.followUpQuestionsElement.style.display = 'block';
  1145. } else {
  1146. this.followUpQuestionsElement.style.display = 'none';
  1147. }
  1148. contentChanged = true;
  1149. }
  1150. let metadataHTML = '';
  1151. let showMetadata = false;
  1152. const hasFullMetadata = this.metadata && Object.keys(this.metadata).length > 1 && this.metadata.model;
  1153. const hasOnlyGenId = this.metadata && this.metadata.generationId && Object.keys(this.metadata).length === 1;
  1154. if (hasFullMetadata) {
  1155. metadataHTML += '<hr class="metadata-separator">';
  1156. metadataHTML += `<div class="metadata-line">Model: ${this.metadata.model}</div>`;
  1157. metadataHTML += `<div class="metadata-line">Tokens: prompt: ${this.metadata.promptTokens} / completion: ${this.metadata.completionTokens}</div>`;
  1158. if (this.metadata.reasoningTokens > 0) {
  1159. metadataHTML += `<div class="metadata-line">Reasoning Tokens: ${this.metadata.reasoningTokens}</div>`;
  1160. }
  1161. metadataHTML += `<div class="metadata-line">Latency: ${this.metadata.latency}</div>`;
  1162. if (this.metadata.mediaInputs > 0) {
  1163. metadataHTML += `<div class="metadata-line">Media: ${this.metadata.mediaInputs}</div>`;
  1164. }
  1165. metadataHTML += `<div class="metadata-line">Price: ${this.metadata.price}</div>`;
  1166. showMetadata = true;
  1167. } else if (hasOnlyGenId) {
  1168. metadataHTML += '<hr class="metadata-separator">';
  1169. metadataHTML += `<div class="metadata-line">Generation ID: ${this.metadata.generationId} (fetching details...)</div>`;
  1170. showMetadata = true;
  1171. }
  1172. if (this.metadataElement.innerHTML !== metadataHTML) {
  1173. this.metadataElement.innerHTML = metadataHTML;
  1174. contentChanged = true;
  1175. }
  1176. if ((this.metadataElement.style.display === 'none') === showMetadata) {
  1177. this.metadataElement.style.display = showMetadata ? 'block' : 'none';
  1178. contentChanged = true;
  1179. }
  1180. const isStreaming = this.status === 'streaming';
  1181. if (this.tooltipElement.classList.contains('streaming-tooltip') !== isStreaming) {
  1182. this.tooltipElement.classList.toggle('streaming-tooltip', isStreaming);
  1183. contentChanged = true;
  1184. }
  1185. if (contentChanged) {
  1186. requestAnimationFrame(() => {
  1187. if (this.autoScroll && (wasNearBottom || !previousScrollHeight)) {
  1188. this._performAutoScroll();
  1189. } else if (!this.autoScroll && previousScrollHeight > 0) {
  1190. const newScrollHeight = this.tooltipElement.scrollHeight;
  1191. const scrollDiff = newScrollHeight - previousScrollHeight;
  1192. this.tooltipElement.scrollTop = previousScrollTop + scrollDiff;
  1193. }
  1194. this._updateScrollButtonVisibility();
  1195. });
  1196. } else {
  1197. this._updateScrollButtonVisibility();
  1198. }
  1199. }
  1200. _renderConversationHistory() {
  1201. if (!this.conversationHistory || this.conversationHistory.length === 0) {
  1202. return '';
  1203. }
  1204. const expandedStates = new Map();
  1205. if (this.conversationContainerElement) {
  1206. this.conversationContainerElement.querySelectorAll('.conversation-reasoning').forEach((dropdown, index) => {
  1207. expandedStates.set(index, dropdown.classList.contains('expanded'));
  1208. });
  1209. }
  1210. let historyHtml = '';
  1211. this.conversationHistory.forEach((turn, index) => {
  1212. const formattedQuestion = turn.question
  1213. .replace(/</g, '&lt;').replace(/>/g, '&gt;');
  1214. let uploadedImageHtml = '';
  1215. if (turn.uploadedImages && turn.uploadedImages.length > 0) {
  1216. uploadedImageHtml = `
  1217. <div class="conversation-image-container">
  1218. ${turn.uploadedImages.map(imageUrl => `
  1219. <img src="${imageUrl}" alt="User uploaded image" class="conversation-uploaded-image">
  1220. `).join('')}
  1221. </div>
  1222. `;
  1223. }
  1224. let formattedAnswer;
  1225. if (turn.answer === 'pending') {
  1226. formattedAnswer = '<em class="pending-answer">Answering...</em>';
  1227. } else {
  1228. formattedAnswer = turn.answer
  1229. .replace(/</g, '&lt;').replace(/>/g, '&gt;') // Escape potential raw HTML first
  1230. .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="ai-generated-link">$1</a>') // Added class
  1231. .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
  1232. .replace(/\*(.*?)\*/g, '<em>$1</em>')
  1233. .replace(/`([^`]+)`/g, '<code>$1</code>')
  1234. .replace(/\n/g, '<br>');
  1235. }
  1236. if (index > 0) {
  1237. historyHtml += '<hr class="conversation-separator">';
  1238. }
  1239. let reasoningHtml = '';
  1240. if (turn.reasoning && turn.reasoning.trim() !== '') {
  1241. const formattedReasoning = formatTooltipDescription("", turn.reasoning).reasoning;
  1242. const wasExpanded = expandedStates.get(index);
  1243. const expandedClass = wasExpanded ? ' expanded' : '';
  1244. const arrowChar = wasExpanded ? '▼' : '▶';
  1245. const contentStyle = wasExpanded ? 'style="max-height: 200px; padding: 8px;"' : 'style="max-height: 0; padding: 0 8px;"';
  1246. reasoningHtml = `
  1247. <div class="reasoning-dropdown conversation-reasoning${expandedClass}" data-index="${index}">
  1248. <div class="reasoning-toggle" role="button" tabindex="0" aria-expanded="${wasExpanded ? 'true' : 'false'}">
  1249. <span class="reasoning-arrow">${arrowChar}</span> Show Reasoning Trace
  1250. </div>
  1251. <div class="reasoning-content" ${contentStyle}>
  1252. <p class="reasoning-text">${formattedReasoning}</p>
  1253. </div>
  1254. </div>
  1255. `;
  1256. }
  1257. historyHtml += `
  1258. <div class="conversation-turn">
  1259. <div class="conversation-question"><strong>You:</strong> ${formattedQuestion}</div>
  1260. ${uploadedImageHtml}
  1261. ${reasoningHtml}
  1262. <div class="conversation-answer"><strong>AI:</strong> ${formattedAnswer}</div>
  1263. </div>
  1264. `;
  1265. });
  1266. if (this.conversationContainerElement) {
  1267. this.conversationContainerElement.innerHTML = historyHtml;
  1268. this._attachConversationReasoningListeners();
  1269. }
  1270. return historyHtml;
  1271. }
  1272. _attachConversationReasoningListeners() {
  1273. if (!this.conversationContainerElement) return;
  1274. this.conversationContainerElement.removeEventListener('click', this._handleConversationReasoningToggle);
  1275. this.conversationContainerElement.addEventListener('click', (e) => {
  1276. const toggleButton = e.target.closest('.conversation-reasoning .reasoning-toggle');
  1277. if (!toggleButton) return;
  1278. e.stopPropagation();
  1279. const dropdown = toggleButton.closest('.reasoning-dropdown');
  1280. const content = dropdown?.querySelector('.reasoning-content');
  1281. const arrow = dropdown?.querySelector('.reasoning-arrow');
  1282. if (!dropdown || !content || !arrow) return;
  1283. const isExpanded = dropdown.classList.toggle('expanded');
  1284. arrow.textContent = isExpanded ? '▼' : '▶';
  1285. toggleButton.setAttribute('aria-expanded', isExpanded);
  1286. content.style.maxHeight = isExpanded ? '200px' : '0';
  1287. content.style.padding = isExpanded ? '8px' : '0 8px';
  1288. });
  1289. }
  1290. _performAutoScroll() {
  1291. if (!this.tooltipElement || !this.autoScroll) return;
  1292. requestAnimationFrame(() => {
  1293. requestAnimationFrame(() => {
  1294. if (this.tooltipElement && this.autoScroll && this.isVisible) {
  1295. const targetScroll = this.tooltipElement.scrollHeight;
  1296. this.tooltipElement.scrollTo({
  1297. top: targetScroll,
  1298. behavior: 'instant'
  1299. });
  1300. setTimeout(() => {
  1301. if (this.tooltipElement && this.autoScroll && this.isVisible) {
  1302. const isNearBottom = this.tooltipElement.scrollHeight - this.tooltipElement.scrollTop - this.tooltipElement.clientHeight < 5;
  1303. if (!isNearBottom) {
  1304. this.tooltipElement.scrollTop = this.tooltipElement.scrollHeight;
  1305. }
  1306. }
  1307. }, 50);
  1308. }
  1309. });
  1310. });
  1311. }
  1312. _setPosition() {
  1313. if (!this.isVisible || !this.indicatorElement || !this.tooltipElement) return;
  1314. const indicatorRect = this.indicatorElement.getBoundingClientRect();
  1315. const tooltip = this.tooltipElement;
  1316. const margin = 10;
  1317. const isMobile = isMobileDevice();
  1318. const viewportHeight = window.innerHeight;
  1319. const viewportWidth = window.innerWidth;
  1320. const safeAreaHeight = viewportHeight - margin;
  1321. const safeAreaWidth = viewportWidth - margin;
  1322. tooltip.style.maxHeight = '';
  1323. tooltip.style.overflowY = '';
  1324. tooltip.style.visibility = 'hidden';
  1325. tooltip.style.display = 'block';
  1326. const computedStyle = window.getComputedStyle(tooltip);
  1327. const tooltipWidth = parseFloat(computedStyle.width);
  1328. let tooltipHeight = parseFloat(computedStyle.height);
  1329. let left, top;
  1330. let finalMaxHeight = '';
  1331. let finalOverflowY = '';
  1332. if (isMobile) {
  1333. left = Math.max(margin, (viewportWidth - tooltipWidth) / 2);
  1334. if (left + tooltipWidth > safeAreaWidth) {
  1335. left = safeAreaWidth - tooltipWidth;
  1336. }
  1337. const maxTooltipHeight = viewportHeight * 0.8;
  1338. if (tooltipHeight > maxTooltipHeight) {
  1339. finalMaxHeight = `${maxTooltipHeight}px`;
  1340. finalOverflowY = 'scroll';
  1341. tooltipHeight = maxTooltipHeight;
  1342. }
  1343. top = Math.max(margin, (viewportHeight - tooltipHeight) / 2);
  1344. if (top + tooltipHeight > safeAreaHeight) {
  1345. top = safeAreaHeight - tooltipHeight;
  1346. }
  1347. } else {
  1348. left = indicatorRect.right + margin;
  1349. top = indicatorRect.top + (indicatorRect.height / 2) - (tooltipHeight / 2);
  1350. if (left + tooltipWidth > safeAreaWidth) {
  1351. left = indicatorRect.left - tooltipWidth - margin;
  1352. if (left < margin) {
  1353. left = Math.max(margin, (viewportWidth - tooltipWidth) / 2);
  1354. if (indicatorRect.bottom + tooltipHeight + margin <= safeAreaHeight) {
  1355. top = indicatorRect.bottom + margin;
  1356. }
  1357. else if (indicatorRect.top - tooltipHeight - margin >= margin) {
  1358. top = indicatorRect.top - tooltipHeight - margin;
  1359. }
  1360. else {
  1361. top = margin;
  1362. finalMaxHeight = `${safeAreaHeight - margin}px`;
  1363. finalOverflowY = 'scroll';
  1364. tooltipHeight = safeAreaHeight - margin;
  1365. }
  1366. }
  1367. }
  1368. if (top < margin) {
  1369. top = margin;
  1370. }
  1371. if (top + tooltipHeight > safeAreaHeight) {
  1372. if (tooltipHeight > safeAreaHeight - margin) {
  1373. top = margin;
  1374. finalMaxHeight = `${safeAreaHeight - margin}px`;
  1375. finalOverflowY = 'scroll';
  1376. } else {
  1377. top = safeAreaHeight - tooltipHeight;
  1378. }
  1379. }
  1380. }
  1381. tooltip.style.position = 'fixed';
  1382. tooltip.style.left = `${left}px`;
  1383. tooltip.style.top = `${top}px`;
  1384. tooltip.style.zIndex = '99999999';
  1385. tooltip.style.maxHeight = finalMaxHeight;
  1386. tooltip.style.overflowY = finalOverflowY;
  1387. if (finalOverflowY === 'scroll') {
  1388. tooltip.style.webkitOverflowScrolling = 'touch';
  1389. }
  1390. tooltip.style.visibility = 'visible';
  1391. }
  1392. _updateScrollButtonVisibility() {
  1393. if (!this.tooltipElement || !this.scrollButton) return;
  1394. const isStreaming = this.status === 'streaming';
  1395. if (!isStreaming) {
  1396. this.scrollButton.style.display = 'none';
  1397. return;
  1398. }
  1399. const isNearBottom = this.tooltipElement.scrollHeight - this.tooltipElement.scrollTop - this.tooltipElement.clientHeight < (isMobileDevice() ? 40 : 55);
  1400. this.scrollButton.style.display = isNearBottom ? 'none' : 'block';
  1401. }
  1402. _handleMouseEnter(event) {
  1403. if (isMobileDevice()) return;
  1404. this.show();
  1405. }
  1406. _handleMouseLeave(event) {
  1407. if (isMobileDevice()) return;
  1408. setTimeout(() => {
  1409. if (this.tooltipElement && !this.tooltipElement.matches(':hover') &&
  1410. this.indicatorElement && !this.indicatorElement.matches(':hover')) {
  1411. this.hide();
  1412. }
  1413. }, 100);
  1414. }
  1415. _handleIndicatorClick(event) {
  1416. event.stopPropagation();
  1417. event.preventDefault();
  1418. this.toggle();
  1419. }
  1420. _handleTooltipMouseEnter() {
  1421. if (!this.isPinned) {
  1422. this.show();
  1423. }
  1424. }
  1425. _handleTooltipMouseLeave() {
  1426. if (!this.isPinned) {
  1427. this.hide();
  1428. }
  1429. }
  1430. _handleTooltipClick(event) {
  1431. }
  1432. _handleTooltipScroll() {
  1433. if (!this.tooltipElement) return;
  1434. const isNearBottom = this.tooltipElement.scrollHeight - this.tooltipElement.scrollTop - this.tooltipElement.clientHeight < (isMobileDevice() ? 40 : 55);
  1435. if (!isNearBottom) {
  1436. if (this.autoScroll) {
  1437. this.autoScroll = false;
  1438. this.tooltipElement.dataset.autoScroll = 'false';
  1439. this.userInitiatedScroll = true;
  1440. }
  1441. } else {
  1442. if (this.userInitiatedScroll) {
  1443. this.autoScroll = true;
  1444. this.tooltipElement.dataset.autoScroll = 'true';
  1445. this.userInitiatedScroll = false;
  1446. }
  1447. }
  1448. this._updateScrollButtonVisibility();
  1449. }
  1450. _handlePinClick(e) {
  1451. e.stopPropagation();
  1452. if (this.isPinned) {
  1453. this.unpin();
  1454. } else {
  1455. this.pin();
  1456. }
  1457. }
  1458. _handleCopyClick(e) {
  1459. e.stopPropagation();
  1460. if (!this.descriptionElement || !this.reasoningTextElement || !this.copyButton) return;
  1461. let textToCopy = this.descriptionElement.textContent || '';
  1462. const reasoningContent = this.reasoningTextElement.textContent || '';
  1463. if (reasoningContent) {
  1464. textToCopy += '\n\nReasoning:\n' + reasoningContent;
  1465. }
  1466. navigator.clipboard.writeText(textToCopy).then(() => {
  1467. const originalText = this.copyButton.innerHTML;
  1468. this.copyButton.innerHTML = '✓';
  1469. this.copyButton.disabled = true;
  1470. setTimeout(() => {
  1471. this.copyButton.innerHTML = originalText;
  1472. this.copyButton.disabled = false;
  1473. }, 1500);
  1474. }).catch(err => {
  1475. });
  1476. }
  1477. _handleReasoningToggleClick(e) {
  1478. e.stopPropagation();
  1479. if (!this.reasoningDropdown || !this.reasoningContent || !this.reasoningArrow) return;
  1480. const isExpanded = this.reasoningDropdown.classList.toggle('expanded');
  1481. this.reasoningArrow.textContent = isExpanded ? '▼' : '▶';
  1482. if (isExpanded) {
  1483. this.reasoningContent.style.maxHeight = '300px';
  1484. this.reasoningContent.style.padding = '10px';
  1485. } else {
  1486. this.reasoningContent.style.maxHeight = '0';
  1487. this.reasoningContent.style.padding = '0 10px';
  1488. }
  1489. }
  1490. _handleScrollButtonClick(e) {
  1491. e.stopPropagation();
  1492. if (!this.tooltipElement) return;
  1493. this.autoScroll = true;
  1494. this.tooltipElement.dataset.autoScroll = 'true';
  1495. this._performAutoScroll();
  1496. this._updateScrollButtonVisibility();
  1497. }
  1498. _handleFollowUpQuestionClick(event) {
  1499. const isMockEvent = event.target && event.target.dataset && event.target.dataset.questionText && typeof event.target.closest !== 'function';
  1500. const button = isMockEvent ? event.target : event.target.closest('.follow-up-question-button');
  1501. if (!button) return;
  1502. event.stopPropagation();
  1503. const questionText = button.dataset.questionText;
  1504. const apiKey = browserGet('openrouter-api-key', '');
  1505. if (!isMockEvent) {
  1506. button.disabled = true;
  1507. button.textContent = `🤔 Asking: ${questionText}...`;
  1508. this.followUpQuestionsElement.querySelectorAll('.follow-up-question-button').forEach(btn => btn.disabled = true);
  1509. } else {
  1510. if (this.customQuestionInput) this.customQuestionInput.disabled = true;
  1511. if (this.customQuestionButton) {
  1512. this.customQuestionButton.disabled = true;
  1513. this.customQuestionButton.textContent = 'Asking...';
  1514. }
  1515. }
  1516. this.conversationHistory.push({
  1517. question: questionText,
  1518. answer: 'pending',
  1519. uploadedImages: [...this.uploadedImageDataUrls], // Store a copy of the image URLs array
  1520. reasoning: '' // Initialize reasoning for this turn
  1521. });
  1522. this._updateTooltipUI();
  1523. this.questions = [];
  1524. this._updateTooltipUI();
  1525. const userMessageContent = [{ type: "text", text: questionText }];
  1526. if (this.uploadedImageDataUrls && this.uploadedImageDataUrls.length > 0) {
  1527. this.uploadedImageDataUrls.forEach(url => {
  1528. userMessageContent.push({ type: "image_url", image_url: { "url": url } });
  1529. });
  1530. }
  1531. const userApiMessage = { role: "user", content: userMessageContent };
  1532. const historyForApiCall = [...this.qaConversationHistory, userApiMessage];
  1533. if (!apiKey) {
  1534. showStatus('API key missing. Cannot answer question.', 'error');
  1535. this._updateConversationHistory(questionText, "Error: API Key missing.", "");
  1536. if (!isMockEvent) {
  1537. button.disabled = false;
  1538. this.followUpQuestionsElement.querySelectorAll('.follow-up-question-button').forEach(btn => btn.disabled = false);
  1539. }
  1540. if (this.customQuestionInput) this.customQuestionInput.disabled = false;
  1541. if (this.customQuestionButton) {
  1542. this.customQuestionButton.disabled = false;
  1543. this.customQuestionButton.textContent = 'Ask';
  1544. }
  1545. this._clearFollowUpImage();
  1546. return;
  1547. }
  1548. if (!questionText) {
  1549. this._updateConversationHistory(questionText || "Error: Empty Question", "Error: Could not identify question.", "");
  1550. if (!isMockEvent) {
  1551. button.disabled = false;
  1552. this.followUpQuestionsElement.querySelectorAll('.follow-up-question-button').forEach(btn => btn.disabled = false);
  1553. }
  1554. if (this.customQuestionInput) this.customQuestionInput.disabled = false;
  1555. if (this.customQuestionButton) {
  1556. this.customQuestionButton.disabled = false;
  1557. this.customQuestionButton.textContent = 'Ask';
  1558. }
  1559. this._clearFollowUpImage();
  1560. return;
  1561. }
  1562. const currentArticle = this.findCurrentArticleElement();
  1563. try {
  1564. answerFollowUpQuestion(this.tweetId, historyForApiCall, apiKey, currentArticle, this);
  1565. } finally {
  1566. setTimeout(() => {
  1567. if (this.followUpQuestionsElement) {
  1568. this.followUpQuestionsElement.querySelectorAll('.follow-up-question-button').forEach(btn => {
  1569. btn.disabled = false;
  1570. });
  1571. }
  1572. if (this.customQuestionInput) this.customQuestionInput.disabled = false;
  1573. if (this.customQuestionButton) {
  1574. this.customQuestionButton.disabled = false;
  1575. this.customQuestionButton.textContent = 'Ask';
  1576. }
  1577. this._clearFollowUpImage();
  1578. }, 100);
  1579. }
  1580. }
  1581. _handleCustomQuestionClick() {
  1582. if (!this.customQuestionInput || !this.customQuestionButton) return;
  1583. const questionText = this.customQuestionInput.value.trim();
  1584. if (!questionText) {
  1585. showStatus("Please enter a question.", "warning");
  1586. this.customQuestionInput.focus();
  1587. return;
  1588. }
  1589. const mockButton = {
  1590. dataset: { questionText: questionText },
  1591. disabled: false,
  1592. textContent: ''
  1593. };
  1594. this.followUpQuestionsElement?.querySelectorAll('.follow-up-question-button').forEach(btn => btn.disabled = true);
  1595. this._handleFollowUpQuestionClick({ target: mockButton, stopPropagation: () => {} });
  1596. if (this.customQuestionInput) {
  1597. this.customQuestionInput.value = '';
  1598. }
  1599. }
  1600. _handleFollowUpImageSelect(event) {
  1601. const files = event.target.files;
  1602. if (!files || files.length === 0) return;
  1603. if (this.followUpImageContainer && files.length > 0) {
  1604. this.followUpImageContainer.style.display = 'flex';
  1605. }
  1606. Array.from(files).forEach(file => {
  1607. if (file && file.type.startsWith('image/')) {
  1608. resizeImage(file, 512) // Resize to max 512px
  1609. .then(resizedDataUrl => {
  1610. this.uploadedImageDataUrls.push(resizedDataUrl);
  1611. this._addPreviewToContainer(resizedDataUrl);
  1612. })
  1613. .catch(error => {
  1614. showStatus(`Could not process image ${file.name}: ${error.message}`, "error");
  1615. });
  1616. } else if (file) {
  1617. showStatus(`Skipping non-image file: ${file.name}`, "warning");
  1618. }
  1619. });
  1620. event.target.value = null;
  1621. }
  1622. _addPreviewToContainer(imageDataUrl) {
  1623. if (!this.followUpImageContainer) return;
  1624. const previewItem = document.createElement('div');
  1625. previewItem.className = 'follow-up-image-preview-item';
  1626. previewItem.dataset.imageDataUrl = imageDataUrl;
  1627. const img = document.createElement('img');
  1628. img.src = imageDataUrl;
  1629. img.className = 'follow-up-image-preview-thumbnail';
  1630. const removeBtn = document.createElement('button');
  1631. removeBtn.textContent = '×';
  1632. removeBtn.className = 'follow-up-image-remove-btn';
  1633. removeBtn.title = 'Remove this image';
  1634. removeBtn.addEventListener('click', (e) => {
  1635. e.stopPropagation();
  1636. this._removeSpecificUploadedImage(imageDataUrl);
  1637. });
  1638. previewItem.appendChild(img);
  1639. previewItem.appendChild(removeBtn);
  1640. this.followUpImageContainer.appendChild(previewItem);
  1641. }
  1642. _removeSpecificUploadedImage(imageDataUrl) {
  1643. this.uploadedImageDataUrls = this.uploadedImageDataUrls.filter(url => url !== imageDataUrl);
  1644. if (this.followUpImageContainer) {
  1645. const previewItemToRemove = this.followUpImageContainer.querySelector(`div.follow-up-image-preview-item[data-image-data-url="${CSS.escape(imageDataUrl)}"]`);
  1646. if (previewItemToRemove) {
  1647. previewItemToRemove.remove();
  1648. }
  1649. if (this.uploadedImageDataUrls.length === 0) {
  1650. this.followUpImageContainer.style.display = 'none';
  1651. }
  1652. }
  1653. }
  1654. _clearFollowUpImage() {
  1655. this.uploadedImageDataUrls = [];
  1656. if (this.followUpImageContainer) {
  1657. this.followUpImageContainer.innerHTML = '';
  1658. this.followUpImageContainer.style.display = 'none';
  1659. }
  1660. if (this.followUpImageInput) {
  1661. this.followUpImageInput.value = null;
  1662. }
  1663. }
  1664. _updateConversationHistory(question, answer, reasoning = '') {
  1665. const entryIndex = this.conversationHistory.findIndex(turn => turn.question === question && turn.answer === 'pending');
  1666. if (entryIndex !== -1) {
  1667. this.conversationHistory[entryIndex].answer = answer;
  1668. this.conversationHistory[entryIndex].reasoning = reasoning;
  1669. this._updateTooltipUI();
  1670. } else {
  1671. }
  1672. }
  1673. _renderStreamingAnswer(streamingText, reasoningText = '') {
  1674. if (!this.conversationContainerElement) return;
  1675. const conversationTurns = this.conversationContainerElement.querySelectorAll('.conversation-turn');
  1676. const lastTurnElement = conversationTurns.length > 0 ? conversationTurns[conversationTurns.length - 1] : null;
  1677. if (!lastTurnElement) {
  1678. return;
  1679. }
  1680. const lastHistoryEntry = this.conversationHistory.length > 0 ? this.conversationHistory[this.conversationHistory.length -1] : null;
  1681. if (!(lastHistoryEntry && lastHistoryEntry.answer === 'pending')) {
  1682. return;
  1683. }
  1684. let reasoningDropdown = lastTurnElement.querySelector('.reasoning-dropdown');
  1685. const hasReasoning = reasoningText && reasoningText.trim() !== '';
  1686. if (hasReasoning && !reasoningDropdown) {
  1687. reasoningDropdown = document.createElement('div');
  1688. reasoningDropdown.className = 'reasoning-dropdown conversation-reasoning';
  1689. const reasoningToggle = document.createElement('div');
  1690. reasoningToggle.className = 'reasoning-toggle';
  1691. const reasoningArrow = document.createElement('span');
  1692. reasoningArrow.className = 'reasoning-arrow';
  1693. reasoningArrow.textContent = '▶';
  1694. reasoningToggle.appendChild(reasoningArrow);
  1695. reasoningToggle.appendChild(document.createTextNode(' Show Reasoning Trace'));
  1696. const reasoningContent = document.createElement('div');
  1697. reasoningContent.className = 'reasoning-content';
  1698. const reasoningTextElement = document.createElement('p');
  1699. reasoningTextElement.className = 'reasoning-text';
  1700. reasoningContent.appendChild(reasoningTextElement);
  1701. reasoningDropdown.appendChild(reasoningToggle);
  1702. reasoningDropdown.appendChild(reasoningContent);
  1703. const answerElement = lastTurnElement.querySelector('.conversation-answer');
  1704. if (answerElement) {
  1705. lastTurnElement.insertBefore(reasoningDropdown, answerElement);
  1706. } else {
  1707. lastTurnElement.appendChild(reasoningDropdown);
  1708. }
  1709. reasoningToggle.addEventListener('click', (e) => {
  1710. e.stopPropagation();
  1711. const dropdown = e.target.closest('.reasoning-dropdown');
  1712. const content = dropdown?.querySelector('.reasoning-content');
  1713. const arrow = dropdown?.querySelector('.reasoning-arrow');
  1714. if (!dropdown || !content || !arrow) return;
  1715. const isExpanded = dropdown.classList.toggle('expanded');
  1716. arrow.textContent = isExpanded ? '▼' : '▶';
  1717. content.style.maxHeight = isExpanded ? '200px' : '0';
  1718. content.style.padding = isExpanded ? '8px' : '0 8px';
  1719. });
  1720. }
  1721. if (reasoningDropdown && hasReasoning) {
  1722. const reasoningTextElement = reasoningDropdown.querySelector('.reasoning-text');
  1723. if (reasoningTextElement) {
  1724. const formattedReasoning = formatTooltipDescription("", reasoningText).reasoning;
  1725. if (reasoningTextElement.innerHTML !== formattedReasoning) {
  1726. reasoningTextElement.innerHTML = formattedReasoning;
  1727. }
  1728. }
  1729. reasoningDropdown.style.display = 'block';
  1730. } else if (reasoningDropdown) {
  1731. reasoningDropdown.style.display = 'none';
  1732. }
  1733. const lastAnswerElement = lastTurnElement.querySelector('.conversation-answer');
  1734. if (lastAnswerElement) {
  1735. const formattedStreamingAnswer = streamingText
  1736. .replace(/</g, '&lt;').replace(/>/g, '&gt;') // Escape potential raw HTML first
  1737. .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="ai-generated-link">$1</a>')
  1738. .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
  1739. .replace(/\*(.*?)\*/g, '<em>$1</em>')
  1740. .replace(/`([^`]+)`/g, '<code>$1</code>')
  1741. .replace(/\n/g, '<br>');
  1742. lastAnswerElement.innerHTML = `<strong>AI:</strong> ${formattedStreamingAnswer}<em class="pending-cursor">|</em>`;
  1743. } else {
  1744. }
  1745. if (this.autoScroll) {
  1746. this._performAutoScroll();
  1747. }
  1748. }
  1749. update({ status, score = null, description = '', reasoning = '', metadata = null, questions = undefined }) {
  1750. const statusChanged = status !== undefined && this.status !== status;
  1751. const scoreChanged = score !== null && this.score !== score;
  1752. const descriptionChanged = description !== '' && this.description !== description;
  1753. const reasoningChanged = reasoning !== '' && this.reasoning !== reasoning;
  1754. const metadataChanged = metadata !== null && JSON.stringify(this.metadata) !== JSON.stringify(metadata);
  1755. const questionsChanged = questions !== undefined && JSON.stringify(this.questions) !== JSON.stringify(questions);
  1756. if (!statusChanged && !scoreChanged && !descriptionChanged && !reasoningChanged && !metadataChanged && !questionsChanged) {
  1757. return;
  1758. }
  1759. if (statusChanged) this.status = status;
  1760. if (scoreChanged || statusChanged) {
  1761. this.score = (this.status === 'pending' || this.status === 'error') ? score : // Allow score display for error state if provided
  1762. (this.status === 'streaming' && score === null) ? this.score : // Keep existing score during streaming if new one is null
  1763. score;
  1764. }
  1765. if (descriptionChanged) this.description = description;
  1766. if (reasoningChanged) this.reasoning = reasoning;
  1767. if (metadataChanged) this.metadata = metadata;
  1768. if (questionsChanged) this.questions = questions;
  1769. if (statusChanged) {
  1770. const shouldAutoScroll = (this.status === 'pending' || this.status === 'streaming');
  1771. if (this.autoScroll !== shouldAutoScroll) {
  1772. this.autoScroll = shouldAutoScroll;
  1773. if (this.tooltipElement) {
  1774. this.tooltipElement.dataset.autoScroll = this.autoScroll ? 'true' : 'false';
  1775. }
  1776. }
  1777. }
  1778. if (statusChanged || scoreChanged) {
  1779. this._updateIndicatorUI();
  1780. }
  1781. if (descriptionChanged || reasoningChanged || statusChanged || metadataChanged || questionsChanged) {
  1782. this._updateTooltipUI();
  1783. } else {
  1784. this._updateScrollButtonVisibility();
  1785. }
  1786. this.updateDatasetAttributes();
  1787. }
  1788. show() {
  1789. if (!this.tooltipElement) return;
  1790. this.isVisible = true;
  1791. this.tooltipElement.style.display = 'block';
  1792. this._setPosition();
  1793. if (this.autoScroll && (this.status === 'streaming' || this.status === 'pending')) {
  1794. this._performAutoScroll();
  1795. }
  1796. this._updateScrollButtonVisibility();
  1797. }
  1798. hide() {
  1799. if (!this.isPinned && this.tooltipElement) {
  1800. this.isVisible = false;
  1801. this.tooltipElement.style.display = 'none';
  1802. } else if (this.isPinned) {
  1803. }
  1804. }
  1805. toggle() {
  1806. if (this.isVisible && !this.isPinned) {
  1807. this.hide();
  1808. } else {
  1809. this.show();
  1810. }
  1811. }
  1812. pin() {
  1813. if (!this.tooltipElement || !this.pinButton) return;
  1814. this.isPinned = true;
  1815. this.tooltipElement.classList.add('pinned');
  1816. this.pinButton.innerHTML = '📍';
  1817. this.pinButton.title = 'Unpin tooltip';
  1818. }
  1819. unpin() {
  1820. if (!this.tooltipElement || !this.pinButton) return;
  1821. this.isPinned = false;
  1822. this.tooltipElement.classList.remove('pinned');
  1823. this.pinButton.innerHTML = '📌';
  1824. this.pinButton.title = 'Pin tooltip (prevents auto-closing)';
  1825. setTimeout(() => {
  1826. if (this.tooltipElement && !this.tooltipElement.matches(':hover') &&
  1827. this.indicatorElement && !this.indicatorElement.matches(':hover')) {
  1828. this.hide();
  1829. }
  1830. }, 0);
  1831. }
  1832. _handleCloseClick(e) {
  1833. e.stopPropagation();
  1834. this.hide();
  1835. }
  1836. destroy() {
  1837. if (window.activeStreamingRequests && window.activeStreamingRequests[this.tweetId]) {
  1838. window.activeStreamingRequests[this.tweetId].abort();
  1839. delete window.activeStreamingRequests[this.tweetId];
  1840. }
  1841. this.indicatorElement?.removeEventListener('mouseenter', this._handleMouseEnter);
  1842. this.indicatorElement?.removeEventListener('mouseleave', this._handleMouseLeave);
  1843. this.indicatorElement?.removeEventListener('click', this._handleIndicatorClick);
  1844. this.tooltipElement?.removeEventListener('mouseenter', this._handleTooltipMouseEnter);
  1845. this.tooltipElement?.removeEventListener('mouseleave', this._handleTooltipMouseLeave);
  1846. this.tooltipElement?.removeEventListener('scroll', this._handleTooltipScroll);
  1847. this.pinButton?.removeEventListener('click', this._handlePinClick);
  1848. this.copyButton?.removeEventListener('click', this._handleCopyClick);
  1849. this.tooltipCloseButton?.removeEventListener('click', this._handleCloseClick);
  1850. this.reasoningToggle?.removeEventListener('click', this._handleReasoningToggleClick);
  1851. this.scrollButton?.removeEventListener('click', this._handleScrollButtonClick);
  1852. this.followUpQuestionsElement?.removeEventListener('click', this._handleFollowUpQuestionClick);
  1853. this.customQuestionButton?.removeEventListener('click', this._handleCustomQuestionClick);
  1854. this.customQuestionInput?.removeEventListener('keydown', (event) => {
  1855. if (event.key === 'Enter') {
  1856. event.preventDefault();
  1857. this._handleCustomQuestionClick();
  1858. }
  1859. });
  1860. this.indicatorElement?.remove();
  1861. this.tooltipElement?.remove();
  1862. ScoreIndicatorRegistry.remove(this.tweetId);
  1863. const currentArticle = this.findCurrentArticleElement();
  1864. if (currentArticle) {
  1865. delete currentArticle.dataset.hasScoreIndicator;
  1866. }
  1867. this.tweetArticle = null;
  1868. this.indicatorElement = null;
  1869. this.tooltipElement = null;
  1870. this.pinButton = null;
  1871. this.copyButton = null;
  1872. this.tooltipCloseButton = null;
  1873. this.reasoningToggle = null;
  1874. this.scrollButton = null;
  1875. this.conversationContainerElement = null;
  1876. this.followUpQuestionsElement = null;
  1877. this.customQuestionContainer = null;
  1878. this.customQuestionInput = null;
  1879. this.customQuestionButton = null;
  1880. this.followUpImageContainer = null;
  1881. this.followUpImageInput = null;
  1882. this.followUpImagePreview = null;
  1883. this.followUpRemoveImageButton = null;
  1884. this.attachImageButton = null;
  1885. this.uploadedImageDataUrls = [];
  1886. }
  1887. ensureIndicatorAttached() {
  1888. if (!this.indicatorElement) return;
  1889. const currentArticle = this.findCurrentArticleElement();
  1890. if (!currentArticle) {
  1891. return;
  1892. }
  1893. if (this.indicatorElement.parentElement !== currentArticle) {
  1894. const currentPosition = window.getComputedStyle(currentArticle).position;
  1895. if (currentPosition !== 'relative' && currentPosition !== 'absolute' && currentPosition !== 'fixed' && currentPosition !== 'sticky') {
  1896. currentArticle.style.position = 'relative';
  1897. }
  1898. currentArticle.appendChild(this.indicatorElement);
  1899. }
  1900. this.updateDatasetAttributes(currentArticle);
  1901. }
  1902. findCurrentArticleElement() {
  1903. const timeline = document.querySelector('main') || document.querySelector('div[data-testid="primaryColumn"]');
  1904. if (!timeline) return null;
  1905. const linkSelector = `a[href*="/status/${this.tweetId}"]`;
  1906. const linkElement = timeline.querySelector(linkSelector);
  1907. const article = linkElement?.closest('article[data-testid="tweet"]');
  1908. if (article) {
  1909. if (getTweetID(article) === this.tweetId) {
  1910. return article;
  1911. }
  1912. }
  1913. const articles = timeline.querySelectorAll('article[data-testid="tweet"]');
  1914. for (const art of articles) {
  1915. if (getTweetID(art) === this.tweetId) {
  1916. return art;
  1917. }
  1918. }
  1919. return null;
  1920. }
  1921. updateInitialReviewAndBuildHistory({ fullContext, mediaUrls, apiResponseContent, reviewSystemPrompt, followUpSystemPrompt }) {
  1922. const analysisMatch = apiResponseContent.match(/<ANALYSIS>([\s\S]*?)<\/ANALYSIS>/);
  1923. const scoreMatch = apiResponseContent.match(/<SCORE>\s*SCORE_(\d+)\s*<\/SCORE>/);
  1924. const initialQuestions = extractFollowUpQuestions(apiResponseContent);
  1925. this.score = scoreMatch ? parseInt(scoreMatch[1], 10) : null;
  1926. this.description = analysisMatch ? analysisMatch[1].trim() : apiResponseContent;
  1927. this.questions = initialQuestions;
  1928. this.status = this.score !== null ? 'rated' : 'error';
  1929. const userMessageContent = [{ type: "text", text: fullContext }];
  1930. mediaUrls.forEach(url => {
  1931. userMessageContent.push({ type: "image_url", image_url: { "url": url } });
  1932. });
  1933. this.qaConversationHistory = [
  1934. { role: "system", content: [{ type: "text", text: reviewSystemPrompt }] },
  1935. { role: "user", content: userMessageContent },
  1936. { role: "assistant", content: [{ type: "text", text: apiResponseContent }] },
  1937. { role: "system", content: [{ type: "text", text: followUpSystemPrompt }] }
  1938. ];
  1939. this._updateIndicatorUI();
  1940. this._updateTooltipUI();
  1941. this.updateDatasetAttributes();
  1942. }
  1943. updateAfterFollowUp({ assistantResponseContent, updatedQaHistory }) {
  1944. this.qaConversationHistory = updatedQaHistory;
  1945. const answerMatch = assistantResponseContent.match(/<ANSWER>([\s\S]*?)<\/ANSWER>/);
  1946. const newFollowUpQuestions = extractFollowUpQuestions(assistantResponseContent);
  1947. const answerText = answerMatch ? answerMatch[1].trim() : assistantResponseContent;
  1948. this.questions = newFollowUpQuestions;
  1949. if (this.conversationHistory.length > 0) {
  1950. const lastTurn = this.conversationHistory[this.conversationHistory.length - 1];
  1951. if (lastTurn.answer === 'pending') {
  1952. lastTurn.answer = answerText;
  1953. }
  1954. }
  1955. this._updateTooltipUI();
  1956. this.updateDatasetAttributes();
  1957. }
  1958. rehydrateFromCache(cachedData) {
  1959. this.score = cachedData.score;
  1960. this.description = cachedData.description;
  1961. this.reasoning = cachedData.reasoning;
  1962. this.questions = cachedData.questions || [];
  1963. this.status = cachedData.status || (cachedData.score !== null ? (cachedData.fromStorage ? 'cached' : 'rated') : 'error');
  1964. this.metadata = cachedData.metadata || null;
  1965. this.qaConversationHistory = cachedData.qaConversationHistory || [];
  1966. this.isPinned = cachedData.isPinned || false;
  1967. this.conversationHistory = [];
  1968. if (this.qaConversationHistory.length > 0) {
  1969. let currentQuestion = null;
  1970. let currentUploadedImages = [];
  1971. let startIndex = 0;
  1972. for(let i=0; i < this.qaConversationHistory.length; i++) {
  1973. if (this.qaConversationHistory[i].role === 'system' && this.qaConversationHistory[i].content[0].text.includes('FOLLOW_UP_SYSTEM_PROMPT')) {
  1974. startIndex = i + 1;
  1975. break;
  1976. }
  1977. if (i === 3 && this.qaConversationHistory[i].role === 'system') {
  1978. startIndex = i + 1;
  1979. }
  1980. }
  1981. for (let i = startIndex; i < this.qaConversationHistory.length; i++) {
  1982. const message = this.qaConversationHistory[i];
  1983. if (message.role === 'user') {
  1984. const textContent = message.content.find(c => c.type === 'text');
  1985. currentQuestion = textContent ? textContent.text : "[Question not found]";
  1986. currentUploadedImages = message.content
  1987. .filter(c => c.type === 'image_url' && c.image_url && c.image_url.url.startsWith('data:image'))
  1988. .map(c => c.image_url.url);
  1989. } else if (message.role === 'assistant' && currentQuestion) {
  1990. const assistantTextContent = message.content.find(c => c.type === 'text');
  1991. const assistantAnswer = assistantTextContent ? assistantTextContent.text : "[Answer not found]";
  1992. const answerMatch = assistantAnswer.match(/<ANSWER>([\s\S]*?)<\/ANSWER>/);
  1993. const uiAnswer = answerMatch ? answerMatch[1].trim() : assistantAnswer;
  1994. this.conversationHistory.push({
  1995. question: currentQuestion,
  1996. answer: uiAnswer,
  1997. uploadedImages: currentUploadedImages,
  1998. reasoning: '' // Reasoning extraction from assistant's full response for UI needs more logic
  1999. });
  2000. currentQuestion = null;
  2001. currentUploadedImages = [];
  2002. }
  2003. }
  2004. }
  2005. if (this.isPinned) {
  2006. this.pinButton.innerHTML = '📍';
  2007. this.tooltipElement?.classList.add('pinned');
  2008. } else {
  2009. this.pinButton.innerHTML = '📌';
  2010. this.tooltipElement?.classList.remove('pinned');
  2011. }
  2012. this._updateIndicatorUI();
  2013. this._updateTooltipUI();
  2014. this.updateDatasetAttributes();
  2015. }
  2016. }
  2017. const ScoreIndicatorRegistry = {
  2018. managers: new Map(),
  2019. get(tweetId, tweetArticle = null) {
  2020. if (!tweetId) {
  2021. return null;
  2022. }
  2023. if (this.managers.has(tweetId)) {
  2024. const existingManager = this.managers.get(tweetId);
  2025. if (tweetArticle && existingManager.tweetArticle !== tweetArticle) {
  2026. }
  2027. return existingManager;
  2028. } else if (tweetArticle) {
  2029. try {
  2030. const existingIndicator = tweetArticle.querySelector(`.score-indicator[data-tweet-id="${tweetId}"]`);
  2031. const existingTooltip = document.querySelector(`.score-description[data-tweet-id="${tweetId}"]`);
  2032. if (existingIndicator || existingTooltip) {
  2033. existingIndicator?.remove();
  2034. existingTooltip?.remove();
  2035. }
  2036. return new ScoreIndicator(tweetArticle);
  2037. } catch (e) {
  2038. return null;
  2039. }
  2040. }
  2041. return null;
  2042. },
  2043. add(tweetId, instance) {
  2044. if (this.managers.has(tweetId)) {
  2045. }
  2046. this.managers.set(tweetId, instance);
  2047. },
  2048. remove(tweetId) {
  2049. if (this.managers.has(tweetId)) {
  2050. this.managers.delete(tweetId);
  2051. }
  2052. },
  2053. cleanupOrphaned() {
  2054. let removedCount = 0;
  2055. const observedTimeline = document.querySelector('main') || document.querySelector('div[data-testid="primaryColumn"]');
  2056. if (!observedTimeline) return;
  2057. const visibleTweetIds = new Set();
  2058. observedTimeline.querySelectorAll('article[data-testid="tweet"]').forEach(article => {
  2059. const id = getTweetID(article);
  2060. if (id) visibleTweetIds.add(id);
  2061. });
  2062. for (const [tweetId, manager] of this.managers.entries()) {
  2063. const isConnected = manager.indicatorElement?.isConnected;
  2064. const isVisible = visibleTweetIds.has(tweetId);
  2065. if (!isConnected || !isVisible) {
  2066. manager.destroy();
  2067. removedCount++;
  2068. }
  2069. }
  2070. },
  2071. destroyAll() {
  2072. [...this.managers.values()].forEach(manager => manager.destroy());
  2073. this.managers.clear();
  2074. }
  2075. };
  2076. function formatTooltipDescription(description = "", reasoning = "") {
  2077. let formattedDescription = description === "*Waiting for analysis...*" ? description :
  2078. (description || "*waiting for content...*")
  2079. .replace(/</g, '&lt;').replace(/>/g, '&gt;') // Escape HTML tags first
  2080. .replace(/^# (.*$)/gm, '<h1>$1</h1>')
  2081. .replace(/^## (.*$)/gm, '<h2>$1</h2>')
  2082. .replace(/^### (.*$)/gm, '<h3>$1</h3>')
  2083. .replace(/^#### (.*$)/gm, '<h4>$1</h4>')
  2084. .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') // Bold
  2085. .replace(/\*(.*?)\*/g, '<em>$1</em>') // Italic
  2086. .replace(/`([^`]+)`/g, '<code>$1</code>') // Inline code
  2087. .replace(/SCORE_(\d+)/g, '<span class="score-highlight">SCORE: $1</span>') // Score highlight class
  2088. .replace(/\n\n/g, '<br><br>') // Paragraph breaks
  2089. .replace(/\n/g, '<br>');
  2090. let formattedReasoning = '';
  2091. if (reasoning && reasoning.trim()) {
  2092. formattedReasoning = reasoning
  2093. .replace(/</g, '&lt;').replace(/>/g, '&gt;')
  2094. .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
  2095. .replace(/\*(.*?)\*/g, '<em>$1</em>')
  2096. .replace(/`([^`]+)`/g, '<code>$1</code>')
  2097. .replace(/\n\n/g, '<br><br>')
  2098. .replace(/\n/g, '<br>');
  2099. }
  2100. return { description: formattedDescription, reasoning: formattedReasoning };
  2101. }
  2102. // ----- ui/ui.js -----
  2103. function toggleElementVisibility(element, toggleButton, openText, closedText) {
  2104. if (!element || !toggleButton) return;
  2105. const isCurrentlyHidden = element.classList.contains('hidden');
  2106. toggleButton.innerHTML = isCurrentlyHidden ? openText : closedText;
  2107. if (isCurrentlyHidden) {
  2108. element.style.display = 'flex';
  2109. element.offsetHeight;
  2110. element.classList.remove('hidden');
  2111. } else {
  2112. element.classList.add('hidden');
  2113. setTimeout(() => {
  2114. if (element.classList.contains('hidden')) {
  2115. element.style.display = 'none';
  2116. }
  2117. }, 500);
  2118. }
  2119. if (element.id === 'tweet-filter-container') {
  2120. const filterToggle = document.getElementById('filter-toggle');
  2121. if (filterToggle) {
  2122. if (!isCurrentlyHidden) {
  2123. setTimeout(() => {
  2124. filterToggle.style.display = 'block';
  2125. }, 500);
  2126. } else {
  2127. filterToggle.style.display = 'none';
  2128. }
  2129. }
  2130. }
  2131. }
  2132. function injectUI() {
  2133. let menuHTML;
  2134. if (MENU) {
  2135. menuHTML = MENU;
  2136. } else {
  2137. menuHTML = browserGet('menuHTML');
  2138. }
  2139. if (!menuHTML) {
  2140. showStatus('Error: Could not load UI components.');
  2141. return null;
  2142. }
  2143. const containerId = 'tweetfilter-root-container';
  2144. let uiContainer = document.getElementById(containerId);
  2145. if (uiContainer) {
  2146. return uiContainer;
  2147. }
  2148. uiContainer = document.createElement('div');
  2149. uiContainer.id = containerId;
  2150. uiContainer.innerHTML = menuHTML;
  2151. document.body.appendChild(uiContainer);
  2152. const versionInfo = uiContainer.querySelector('#version-info');
  2153. if (versionInfo) {
  2154. versionInfo.textContent = `Twitter De-Sloppifier v${VERSION}`;
  2155. }
  2156. return uiContainer;
  2157. }
  2158. function initializeEventListeners(uiContainer) {
  2159. if (!uiContainer) {
  2160. return;
  2161. }
  2162. const settingsContainer = uiContainer.querySelector('#settings-container');
  2163. const filterContainer = uiContainer.querySelector('#tweet-filter-container');
  2164. const settingsToggleBtn = uiContainer.querySelector('#settings-toggle');
  2165. const filterToggleBtn = uiContainer.querySelector('#filter-toggle');
  2166. uiContainer.addEventListener('click', (event) => {
  2167. const target = event.target;
  2168. const actionElement = target.closest('[data-action]');
  2169. const action = actionElement?.dataset.action;
  2170. const setting = target.dataset.setting;
  2171. const paramName = target.closest('.parameter-row')?.dataset.paramName;
  2172. const tab = target.dataset.tab;
  2173. const toggleTargetId = target.closest('[data-toggle]')?.dataset.toggle;
  2174. if (action) {
  2175. switch (action) {
  2176. case 'close-filter':
  2177. toggleElementVisibility(filterContainer, filterToggleBtn, 'Filter Slider', 'Filter Slider');
  2178. break;
  2179. case 'toggle-settings':
  2180. case 'close-settings':
  2181. toggleElementVisibility(settingsContainer, settingsToggleBtn, '<span style="font-size: 14px;">✕</span> Close', '<span style="font-size: 14px;">⚙️</span> Settings');
  2182. break;
  2183. case 'save-api-key':
  2184. saveApiKey();
  2185. break;
  2186. case 'clear-cache':
  2187. clearTweetRatingsAndRefreshUI();
  2188. break;
  2189. case 'reset-settings':
  2190. resetSettings(isMobileDevice());
  2191. break;
  2192. case 'save-instructions':
  2193. saveInstructions();
  2194. break;
  2195. case 'add-handle':
  2196. addHandleFromInput();
  2197. break;
  2198. case 'clear-instructions-history':
  2199. clearInstructionsHistory();
  2200. break;
  2201. case 'export-cache':
  2202. exportCacheToJson();
  2203. break;
  2204. }
  2205. }
  2206. if (target.classList.contains('remove-handle')) {
  2207. const handleItem = target.closest('.handle-item');
  2208. const handleTextElement = handleItem?.querySelector('.handle-text');
  2209. if (handleTextElement) {
  2210. const handle = handleTextElement.textContent.substring(1);
  2211. removeHandleFromBlacklist(handle);
  2212. }
  2213. }
  2214. if (tab) {
  2215. switchTab(tab);
  2216. }
  2217. if (toggleTargetId) {
  2218. toggleAdvancedOptions(toggleTargetId);
  2219. }
  2220. });
  2221. uiContainer.addEventListener('input', (event) => {
  2222. const target = event.target;
  2223. const setting = target.dataset.setting;
  2224. const paramName = target.closest('.parameter-row')?.dataset.paramName;
  2225. if (setting) {
  2226. handleSettingChange(target, setting);
  2227. }
  2228. if (paramName) {
  2229. handleParameterChange(target, paramName);
  2230. }
  2231. if (target.id === 'tweet-filter-slider') {
  2232. handleFilterSliderChange(target);
  2233. }
  2234. if (target.id === 'tweet-filter-value') {
  2235. handleFilterValueInput(target);
  2236. }
  2237. });
  2238. uiContainer.addEventListener('change', (event) => {
  2239. const target = event.target;
  2240. const setting = target.dataset.setting;
  2241. if (setting === 'modelSortOrder') {
  2242. handleSettingChange(target, setting);
  2243. fetchAvailableModels();
  2244. }
  2245. if (setting === 'enableImageDescriptions') {
  2246. handleSettingChange(target, setting);
  2247. }
  2248. });
  2249. if (filterToggleBtn) {
  2250. filterToggleBtn.onclick = () => {
  2251. if (filterContainer) {
  2252. filterContainer.style.display = 'flex';
  2253. filterContainer.offsetHeight;
  2254. filterContainer.classList.remove('hidden');
  2255. }
  2256. filterToggleBtn.style.display = 'none';
  2257. };
  2258. }
  2259. document.addEventListener('click', closeAllSelectBoxes);
  2260. const showFreeModelsCheckbox = uiContainer.querySelector('#show-free-models');
  2261. if (showFreeModelsCheckbox) {
  2262. showFreeModelsCheckbox.addEventListener('change', function () {
  2263. showFreeModels = this.checked;
  2264. browserSet('showFreeModels', showFreeModels);
  2265. refreshModelsUI();
  2266. });
  2267. }
  2268. const sortDirectionBtn = uiContainer.querySelector('#sort-direction');
  2269. if (sortDirectionBtn) {
  2270. sortDirectionBtn.addEventListener('click', function () {
  2271. const currentDirection = browserGet('sortDirection', 'default');
  2272. const newDirection = currentDirection === 'default' ? 'reverse' : 'default';
  2273. browserSet('sortDirection', newDirection);
  2274. this.dataset.value = newDirection;
  2275. refreshModelsUI();
  2276. });
  2277. }
  2278. const modelSortSelect = uiContainer.querySelector('#model-sort-order');
  2279. if (modelSortSelect) {
  2280. modelSortSelect.addEventListener('change', function () {
  2281. browserSet('modelSortOrder', this.value);
  2282. if (this.value === 'latency-low-to-high') {
  2283. browserSet('sortDirection', 'default');
  2284. } else if (this.value === '') {
  2285. browserSet('sortDirection', 'default');
  2286. }
  2287. refreshModelsUI();
  2288. });
  2289. }
  2290. const providerSortSelect = uiContainer.querySelector('#provider-sort');
  2291. if (providerSortSelect) {
  2292. providerSortSelect.addEventListener('change', function () {
  2293. providerSort = this.value;
  2294. browserSet('providerSort', providerSort);
  2295. });
  2296. }
  2297. }
  2298. function saveApiKey() {
  2299. const apiKeyInput = document.getElementById('openrouter-api-key');
  2300. const apiKey = apiKeyInput.value.trim();
  2301. let previousAPIKey = browserGet('openrouter-api-key', '').length > 0 ? true : false;
  2302. if (apiKey) {
  2303. if (!previousAPIKey) {
  2304. resetSettings(true);
  2305. }
  2306. browserSet('openrouter-api-key', apiKey);
  2307. showStatus('API key saved successfully!');
  2308. fetchAvailableModels();
  2309. location.reload();
  2310. } else {
  2311. showStatus('Please enter a valid API key');
  2312. }
  2313. }
  2314. function exportCacheToJson() {
  2315. if (!tweetCache) {
  2316. showStatus('Error: Tweet cache not found.', 'error');
  2317. return;
  2318. }
  2319. try {
  2320. const cacheData = tweetCache.cache;
  2321. if (!cacheData || Object.keys(cacheData).length === 0) {
  2322. showStatus('Cache is empty. Nothing to export.', 'warning');
  2323. return;
  2324. }
  2325. const jsonString = JSON.stringify(cacheData, null, 2);
  2326. const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
  2327. const url = URL.createObjectURL(blob);
  2328. const link = document.createElement('a');
  2329. link.setAttribute('href', url);
  2330. const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  2331. link.setAttribute('download', `tweet-filter-cache-${timestamp}.json`);
  2332. link.style.visibility = 'hidden';
  2333. document.body.appendChild(link);
  2334. link.click();
  2335. document.body.removeChild(link);
  2336. URL.revokeObjectURL(url);
  2337. showStatus(`Cache exported successfully (${Object.keys(cacheData).length} items).`);
  2338. } catch (error) {
  2339. showStatus('Error exporting cache. Check console for details.', 'error');
  2340. }
  2341. }
  2342. function clearTweetRatingsAndRefreshUI() {
  2343. if (isMobileDevice() || confirm('Are you sure you want to clear all cached tweet ratings?')) {
  2344. tweetCache.clear(true);
  2345. pendingRequests = 0;
  2346. if (window.threadRelationships) {
  2347. window.threadRelationships = {};
  2348. browserSet('threadRelationships', '{}');
  2349. }
  2350. showStatus('All cached ratings and thread relationships cleared!');
  2351. if (observedTargetNode) {
  2352. observedTargetNode.querySelectorAll('article[data-testid="tweet"]').forEach(tweet => {
  2353. tweet.removeAttribute('data-sloppiness-score');
  2354. tweet.removeAttribute('data-rating-status');
  2355. tweet.removeAttribute('data-rating-description');
  2356. tweet.removeAttribute('data-cached-rating');
  2357. const indicator = tweet.querySelector('.score-indicator');
  2358. if (indicator) {
  2359. indicator.remove();
  2360. }
  2361. const tweetId = getTweetID(tweet);
  2362. if (tweetId) {
  2363. processedTweets.delete(tweetId);
  2364. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId);
  2365. if (indicatorInstance) {
  2366. indicatorInstance.destroy();
  2367. }
  2368. scheduleTweetProcessing(tweet);
  2369. }
  2370. });
  2371. }
  2372. document.querySelectorAll('div[aria-label="Timeline: Conversation"], div[aria-label^="Timeline: Conversation"]').forEach(conversation => {
  2373. delete conversation.dataset.threadMapping;
  2374. delete conversation.dataset.threadMappedAt;
  2375. delete conversation.dataset.threadMappingInProgress;
  2376. delete conversation.dataset.threadHist;
  2377. delete conversation.dataset.threadMediaUrls;
  2378. });
  2379. }
  2380. }
  2381. function addHandleFromInput() {
  2382. const handleInput = document.getElementById('handle-input');
  2383. const handle = handleInput.value.trim();
  2384. if (handle) {
  2385. addHandleToBlacklist(handle);
  2386. handleInput.value = '';
  2387. }
  2388. }
  2389. function handleSettingChange(target, settingName) {
  2390. let value;
  2391. if (target.type === 'checkbox') {
  2392. value = target.checked;
  2393. } else {
  2394. value = target.value;
  2395. }
  2396. if (window[settingName] !== undefined) {
  2397. window[settingName] = value;
  2398. }
  2399. browserSet(settingName, value);
  2400. if (settingName === 'enableImageDescriptions') {
  2401. const imageModelContainer = document.getElementById('image-model-container');
  2402. if (imageModelContainer) {
  2403. imageModelContainer.style.display = value ? 'block' : 'none';
  2404. }
  2405. showStatus('Image descriptions ' + (value ? 'enabled' : 'disabled'));
  2406. }
  2407. }
  2408. function handleParameterChange(target, paramName) {
  2409. const row = target.closest('.parameter-row');
  2410. if (!row) return;
  2411. const slider = row.querySelector('.parameter-slider');
  2412. const valueInput = row.querySelector('.parameter-value');
  2413. const min = parseFloat(slider.min);
  2414. const max = parseFloat(slider.max);
  2415. let newValue = parseFloat(target.value);
  2416. if (target.type === 'number' && !isNaN(newValue)) {
  2417. newValue = Math.max(min, Math.min(max, newValue));
  2418. }
  2419. if (slider && valueInput) {
  2420. slider.value = newValue;
  2421. valueInput.value = newValue;
  2422. }
  2423. if (window[paramName] !== undefined) {
  2424. window[paramName] = newValue;
  2425. }
  2426. browserSet(paramName, newValue);
  2427. }
  2428. function handleFilterSliderChange(slider) {
  2429. const valueInput = document.getElementById('tweet-filter-value');
  2430. currentFilterThreshold = parseInt(slider.value, 10);
  2431. if (valueInput) {
  2432. valueInput.value = currentFilterThreshold.toString();
  2433. }
  2434. const percentage = (currentFilterThreshold / 10) * 100;
  2435. slider.style.setProperty('--slider-percent', `${percentage}%`);
  2436. browserSet('filterThreshold', currentFilterThreshold);
  2437. applyFilteringToAll();
  2438. }
  2439. function handleFilterValueInput(input) {
  2440. let value = parseInt(input.value, 10);
  2441. value = Math.max(0, Math.min(10, value));
  2442. input.value = value.toString();
  2443. const slider = document.getElementById('tweet-filter-slider');
  2444. if (slider) {
  2445. slider.value = value.toString();
  2446. const percentage = (value / 10) * 100;
  2447. slider.style.setProperty('--slider-percent', `${percentage}%`);
  2448. }
  2449. currentFilterThreshold = value;
  2450. browserSet('filterThreshold', currentFilterThreshold);
  2451. applyFilteringToAll();
  2452. }
  2453. function switchTab(tabName) {
  2454. const settingsContent = document.querySelector('#settings-container .settings-content');
  2455. if (!settingsContent) return;
  2456. const tabs = settingsContent.querySelectorAll('.tab-content');
  2457. const buttons = settingsContent.querySelectorAll('.tab-navigation .tab-button');
  2458. tabs.forEach(tab => tab.classList.remove('active'));
  2459. buttons.forEach(btn => btn.classList.remove('active'));
  2460. const tabToShow = settingsContent.querySelector(`#${tabName}-tab`);
  2461. const buttonToActivate = settingsContent.querySelector(`.tab-navigation .tab-button[data-tab="${tabName}"]`);
  2462. if (tabToShow) tabToShow.classList.add('active');
  2463. if (buttonToActivate) buttonToActivate.classList.add('active');
  2464. }
  2465. function toggleAdvancedOptions(contentId) {
  2466. const content = document.getElementById(contentId);
  2467. const toggle = document.querySelector(`[data-toggle="${contentId}"]`);
  2468. if (!content || !toggle) return;
  2469. const icon = toggle.querySelector('.advanced-toggle-icon');
  2470. const isExpanded = content.classList.toggle('expanded');
  2471. if (icon) {
  2472. icon.classList.toggle('expanded', isExpanded);
  2473. }
  2474. if (isExpanded) {
  2475. content.style.maxHeight = content.scrollHeight + 'px';
  2476. } else {
  2477. content.style.maxHeight = '0';
  2478. }
  2479. }
  2480. function refreshSettingsUI() {
  2481. document.querySelectorAll('[data-setting]').forEach(input => {
  2482. const settingName = input.dataset.setting;
  2483. const value = browserGet(settingName, window[settingName]);
  2484. if (input.type === 'checkbox') {
  2485. input.checked = value;
  2486. handleSettingChange(input, settingName);
  2487. } else {
  2488. input.value = value;
  2489. }
  2490. });
  2491. document.querySelectorAll('.parameter-row[data-param-name]').forEach(row => {
  2492. const paramName = row.dataset.paramName;
  2493. const slider = row.querySelector('.parameter-slider');
  2494. const valueInput = row.querySelector('.parameter-value');
  2495. const value = browserGet(paramName, window[paramName]);
  2496. if (slider) slider.value = value;
  2497. if (valueInput) valueInput.value = value;
  2498. });
  2499. const filterSlider = document.getElementById('tweet-filter-slider');
  2500. const filterValueInput = document.getElementById('tweet-filter-value');
  2501. const currentThreshold = browserGet('filterThreshold', '5');
  2502. if (filterSlider && filterValueInput) {
  2503. filterSlider.value = currentThreshold;
  2504. filterValueInput.value = currentThreshold;
  2505. const percentage = (parseInt(currentThreshold, 10) / 10) * 100;
  2506. filterSlider.style.setProperty('--slider-percent', `${percentage}%`);
  2507. }
  2508. refreshHandleList(document.getElementById('handle-list'));
  2509. refreshModelsUI();
  2510. document.querySelectorAll('.advanced-content').forEach(content => {
  2511. if (!content.classList.contains('expanded')) {
  2512. content.style.maxHeight = '0';
  2513. }
  2514. });
  2515. document.querySelectorAll('.advanced-toggle-icon.expanded').forEach(icon => {
  2516. if (!icon.closest('.advanced-toggle')?.nextElementSibling?.classList.contains('expanded')) {
  2517. icon.classList.remove('expanded');
  2518. }
  2519. });
  2520. refreshInstructionsHistory();
  2521. }
  2522. function refreshHandleList(listElement) {
  2523. if (!listElement) return;
  2524. listElement.innerHTML = '';
  2525. if (blacklistedHandles.length === 0) {
  2526. const emptyMsg = document.createElement('div');
  2527. emptyMsg.style.cssText = 'padding: 8px; opacity: 0.7; font-style: italic;';
  2528. emptyMsg.textContent = 'No handles added yet';
  2529. listElement.appendChild(emptyMsg);
  2530. return;
  2531. }
  2532. blacklistedHandles.forEach(handle => {
  2533. const item = document.createElement('div');
  2534. item.className = 'handle-item';
  2535. const handleText = document.createElement('div');
  2536. handleText.className = 'handle-text';
  2537. handleText.textContent = '@' + handle;
  2538. item.appendChild(handleText);
  2539. const removeBtn = document.createElement('button');
  2540. removeBtn.className = 'remove-handle';
  2541. removeBtn.textContent = '×';
  2542. removeBtn.title = 'Remove from list';
  2543. item.appendChild(removeBtn);
  2544. listElement.appendChild(item);
  2545. });
  2546. }
  2547. function refreshModelsUI() {
  2548. const modelSelectContainer = document.getElementById('model-select-container');
  2549. const imageModelSelectContainer = document.getElementById('image-model-select-container');
  2550. listedModels = [...availableModels];
  2551. if (!showFreeModels) {
  2552. listedModels = listedModels.filter(model => !model.slug.endsWith(':free'));
  2553. }
  2554. const sortDirection = browserGet('sortDirection', 'default');
  2555. const sortOrder = browserGet('modelSortOrder', 'throughput-high-to-low');
  2556. const toggleBtn = document.getElementById('sort-direction');
  2557. if (toggleBtn) {
  2558. switch (sortOrder) {
  2559. case 'latency-low-to-high':
  2560. toggleBtn.textContent = sortDirection === 'default' ? 'High-Low' : 'Low-High';
  2561. if (sortDirection === 'reverse') listedModels.reverse();
  2562. break;
  2563. case '': // Age
  2564. toggleBtn.textContent = sortDirection === 'default' ? 'New-Old' : 'Old-New';
  2565. if (sortDirection === 'reverse') listedModels.reverse();
  2566. break;
  2567. case 'top-weekly':
  2568. toggleBtn.textContent = sortDirection === 'default' ? 'Most Popular' : 'Least Popular';
  2569. if (sortDirection === 'reverse') listedModels.reverse();
  2570. break;
  2571. default:
  2572. toggleBtn.textContent = sortDirection === 'default' ? 'High-Low' : 'Low-High';
  2573. if (sortDirection === 'reverse') listedModels.reverse();
  2574. }
  2575. }
  2576. if (modelSelectContainer) {
  2577. modelSelectContainer.innerHTML = '';
  2578. createCustomSelect(
  2579. modelSelectContainer,
  2580. 'model-selector',
  2581. listedModels.map(model => ({ value: model.slug || model.id, label: formatModelLabel(model) })),
  2582. selectedModel,
  2583. (newValue) => {
  2584. selectedModel = newValue;
  2585. browserSet('selectedModel', selectedModel);
  2586. showStatus('Rating model updated');
  2587. },
  2588. 'Search rating models...'
  2589. );
  2590. }
  2591. if (imageModelSelectContainer) {
  2592. const visionModels = listedModels.filter(model =>
  2593. model.input_modalities?.includes('image') ||
  2594. model.architecture?.input_modalities?.includes('image') ||
  2595. model.architecture?.modality?.includes('image')
  2596. );
  2597. imageModelSelectContainer.innerHTML = '';
  2598. createCustomSelect(
  2599. imageModelSelectContainer,
  2600. 'image-model-selector',
  2601. visionModels.map(model => ({ value: model.slug || model.id, label: formatModelLabel(model) })),
  2602. selectedImageModel,
  2603. (newValue) => {
  2604. selectedImageModel = newValue;
  2605. browserSet('selectedImageModel', selectedImageModel);
  2606. showStatus('Image model updated');
  2607. },
  2608. 'Search vision models...'
  2609. );
  2610. }
  2611. }
  2612. function formatModelLabel(model) {
  2613. let label = model.slug || model.id || model.name || 'Unknown Model';
  2614. let pricingInfo = '';
  2615. const pricing = model.endpoint?.pricing || model.pricing;
  2616. if (pricing) {
  2617. const promptPrice = parseFloat(pricing.prompt);
  2618. const completionPrice = parseFloat(pricing.completion);
  2619. if (!isNaN(promptPrice)) {
  2620. pricingInfo += ` - $${(promptPrice * 1e6).toFixed(4)}/mil. tok.-in`;
  2621. if (!isNaN(completionPrice) && completionPrice !== promptPrice) {
  2622. pricingInfo += ` $${(completionPrice * 1e6).toFixed(4)}/mil. tok.-out`;
  2623. }
  2624. } else if (!isNaN(completionPrice)) {
  2625. pricingInfo += ` - $${(completionPrice * 1e6).toFixed(4)}/mil. tok.-out`;
  2626. }
  2627. }
  2628. const isVision = model.input_modalities?.includes('image') ||
  2629. model.architecture?.input_modalities?.includes('image') ||
  2630. model.architecture?.modality?.includes('image');
  2631. if (isVision) {
  2632. label = '🖼️ ' + label;
  2633. }
  2634. return label + pricingInfo;
  2635. }
  2636. function createCustomSelect(container, id, options, initialSelectedValue, onChange, searchPlaceholder) {
  2637. let currentSelectedValue = initialSelectedValue;
  2638. const customSelect = document.createElement('div');
  2639. customSelect.className = 'custom-select';
  2640. customSelect.id = id;
  2641. const selectSelected = document.createElement('div');
  2642. selectSelected.className = 'select-selected';
  2643. const selectItems = document.createElement('div');
  2644. selectItems.className = 'select-items';
  2645. selectItems.style.display = 'none';
  2646. const searchField = document.createElement('div');
  2647. searchField.className = 'search-field';
  2648. const searchInput = document.createElement('input');
  2649. searchInput.type = 'text';
  2650. searchInput.className = 'search-input';
  2651. searchInput.placeholder = searchPlaceholder || 'Search...';
  2652. searchField.appendChild(searchInput);
  2653. selectItems.appendChild(searchField);
  2654. function renderOptions(filter = '') {
  2655. while (selectItems.childNodes.length > 1) {
  2656. selectItems.removeChild(selectItems.lastChild);
  2657. }
  2658. const filteredOptions = options.filter(opt =>
  2659. opt.label.toLowerCase().includes(filter.toLowerCase())
  2660. );
  2661. if (filteredOptions.length === 0) {
  2662. const noResults = document.createElement('div');
  2663. noResults.textContent = 'No matches found';
  2664. noResults.style.cssText = 'opacity: 0.7; font-style: italic; padding: 10px; text-align: center; cursor: default;';
  2665. selectItems.appendChild(noResults);
  2666. }
  2667. filteredOptions.forEach(option => {
  2668. const optionDiv = document.createElement('div');
  2669. optionDiv.textContent = option.label;
  2670. optionDiv.dataset.value = option.value;
  2671. if (option.value === currentSelectedValue) {
  2672. optionDiv.classList.add('same-as-selected');
  2673. }
  2674. optionDiv.addEventListener('click', (e) => {
  2675. e.stopPropagation();
  2676. currentSelectedValue = option.value;
  2677. selectSelected.textContent = option.label;
  2678. selectItems.style.display = 'none';
  2679. selectSelected.classList.remove('select-arrow-active');
  2680. selectItems.querySelectorAll('div[data-value]').forEach(div => {
  2681. div.classList.toggle('same-as-selected', div.dataset.value === currentSelectedValue);
  2682. });
  2683. onChange(currentSelectedValue);
  2684. });
  2685. selectItems.appendChild(optionDiv);
  2686. });
  2687. }
  2688. const initialOption = options.find(opt => opt.value === currentSelectedValue);
  2689. selectSelected.textContent = initialOption ? initialOption.label : 'Select an option';
  2690. customSelect.appendChild(selectSelected);
  2691. customSelect.appendChild(selectItems);
  2692. container.appendChild(customSelect);
  2693. renderOptions();
  2694. searchInput.addEventListener('input', () => renderOptions(searchInput.value));
  2695. searchInput.addEventListener('click', e => e.stopPropagation());
  2696. selectSelected.addEventListener('click', (e) => {
  2697. e.stopPropagation();
  2698. closeAllSelectBoxes(customSelect);
  2699. const isHidden = selectItems.style.display === 'none';
  2700. selectItems.style.display = isHidden ? 'block' : 'none';
  2701. selectSelected.classList.toggle('select-arrow-active', isHidden);
  2702. if (isHidden) {
  2703. searchInput.focus();
  2704. searchInput.select();
  2705. renderOptions();
  2706. }
  2707. });
  2708. }
  2709. function closeAllSelectBoxes(exceptThisOne = null) {
  2710. document.querySelectorAll('.custom-select').forEach(select => {
  2711. if (select === exceptThisOne) return;
  2712. const items = select.querySelector('.select-items');
  2713. const selected = select.querySelector('.select-selected');
  2714. if (items) items.style.display = 'none';
  2715. if (selected) selected.classList.remove('select-arrow-active');
  2716. });
  2717. }
  2718. function resetSettings(noconfirm = false) {
  2719. if (noconfirm || confirm('Are you sure you want to reset all settings to their default values? This will not clear your cached ratings, blacklisted handles, or instruction history.')) {
  2720. tweetCache.clear();
  2721. const defaults = {
  2722. selectedModel: 'openai/gpt-4.1-nano',
  2723. selectedImageModel: 'openai/gpt-4.1-nano',
  2724. enableImageDescriptions: false,
  2725. enableStreaming: true,
  2726. modelTemperature: 0.5,
  2727. modelTopP: 0.9,
  2728. imageModelTemperature: 0.5,
  2729. imageModelTopP: 0.9,
  2730. maxTokens: 0,
  2731. filterThreshold: 5,
  2732. userDefinedInstructions: 'Rate the tweet on a scale from 1 to 10 based on its clarity, insight, creativity, and overall quality.',
  2733. modelSortOrder: 'throughput-high-to-low'
  2734. };
  2735. for (const key in defaults) {
  2736. if (window[key] !== undefined) {
  2737. window[key] = defaults[key];
  2738. }
  2739. browserSet(key, defaults[key]);
  2740. }
  2741. refreshSettingsUI();
  2742. fetchAvailableModels();
  2743. showStatus('Settings reset to defaults');
  2744. }
  2745. }
  2746. function addHandleToBlacklist(handle) {
  2747. handle = handle.trim().replace(/^@/, '');
  2748. if (handle === '' || blacklistedHandles.includes(handle)) {
  2749. showStatus(handle === '' ? 'Handle cannot be empty.' : `@${handle} is already on the list.`);
  2750. return;
  2751. }
  2752. blacklistedHandles.push(handle);
  2753. browserSet('blacklistedHandles', blacklistedHandles.join('\n'));
  2754. refreshHandleList(document.getElementById('handle-list'));
  2755. showStatus(`Added @${handle} to auto-rate list.`);
  2756. }
  2757. function removeHandleFromBlacklist(handle) {
  2758. const index = blacklistedHandles.indexOf(handle);
  2759. if (index > -1) {
  2760. blacklistedHandles.splice(index, 1);
  2761. browserSet('blacklistedHandles', blacklistedHandles.join('\n'));
  2762. refreshHandleList(document.getElementById('handle-list'));
  2763. showStatus(`Removed @${handle} from auto-rate list.`);
  2764. } else console.warn(`Attempted to remove non-existent handle: ${handle}`);
  2765. }
  2766. function initialiseUI() {
  2767. const uiContainer = injectUI();
  2768. if (!uiContainer) return;
  2769. initializeEventListeners(uiContainer);
  2770. refreshSettingsUI();
  2771. fetchAvailableModels();
  2772. initializeFloatingCacheStats();
  2773. setInterval(updateCacheStatsUI, 3000);
  2774. if (!window.activeStreamingRequests) window.activeStreamingRequests = {};
  2775. }
  2776. function initializeFloatingCacheStats() {
  2777. const statsBadge = document.getElementById('tweet-filter-stats-badge');
  2778. if (!statsBadge) return;
  2779. statsBadge.title = 'Click to open settings';
  2780. statsBadge.addEventListener('click', () => {
  2781. const settingsToggle = document.getElementById('settings-toggle');
  2782. if (settingsToggle) {
  2783. settingsToggle.click();
  2784. }
  2785. });
  2786. let fadeTimeout;
  2787. const resetFadeTimeout = () => {
  2788. clearTimeout(fadeTimeout);
  2789. statsBadge.style.opacity = '1';
  2790. fadeTimeout = setTimeout(() => {
  2791. statsBadge.style.opacity = '0.3';
  2792. }, 5000);
  2793. };
  2794. statsBadge.addEventListener('mouseenter', () => {
  2795. statsBadge.style.opacity = '1';
  2796. clearTimeout(fadeTimeout);
  2797. });
  2798. statsBadge.addEventListener('mouseleave', resetFadeTimeout);
  2799. resetFadeTimeout();
  2800. updateCacheStatsUI();
  2801. }
  2802. // ----- ratingEngine.js -----
  2803. function filterSingleTweet(tweetArticle) {
  2804. const cell = tweetArticle.closest('div[data-testid="cellInnerDiv"]');
  2805. if (!cell) {
  2806. return;
  2807. }
  2808. const handles = getUserHandles(tweetArticle);
  2809. const authorHandle = handles.length > 0 ? handles[0] : '';
  2810. if (authorHandle && adAuthorCache.has(authorHandle)) {
  2811. const tweetId = getTweetID(tweetArticle);
  2812. if (tweetId) {
  2813. ScoreIndicatorRegistry.get(tweetId)?.destroy();
  2814. }
  2815. cell.innerHTML = '';
  2816. cell.dataset.filtered = 'true';
  2817. cell.dataset.isAd = 'true';
  2818. return;
  2819. }
  2820. const score = parseInt(tweetArticle.dataset.sloppinessScore || '9', 10);
  2821. const tweetId = getTweetID(tweetArticle);
  2822. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId);
  2823. indicatorInstance?.ensureIndicatorAttached();
  2824. const currentFilterThreshold = parseInt(browserGet('filterThreshold', '1'));
  2825. const ratingStatus = tweetArticle.dataset.ratingStatus;
  2826. if (ratingStatus === 'pending' || ratingStatus === 'streaming') {
  2827. delete cell.dataset.filtered;
  2828. } else if (isNaN(score) || score < currentFilterThreshold) {
  2829. if (tweetId) {
  2830. ScoreIndicatorRegistry.get(tweetId)?.destroy();
  2831. }
  2832. cell.innerHTML = '';
  2833. cell.dataset.filtered = 'true';
  2834. }
  2835. }
  2836. async function applyTweetCachedRating(tweetArticle) {
  2837. const tweetId = getTweetID(tweetArticle);
  2838. const handles = getUserHandles(tweetArticle);
  2839. const userHandle = handles.length > 0 ? handles[0] : '';
  2840. if (userHandle && isUserBlacklisted(userHandle)) {
  2841. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle);
  2842. if (indicatorInstance) {
  2843. const tweetText = getElementText(tweetArticle.querySelector(TWEET_TEXT_SELECTOR)) || "[Tweet text not found]";
  2844. const mediaUrls = await extractMediaLinks(tweetArticle);
  2845. const blacklistResponse = `<ANALYSIS>
  2846. This user is on the blacklist. Tweets from this user are not rated by the AI and are always shown.
  2847. </ANALYSIS>
  2848. <SCORE>
  2849. SCORE_10
  2850. </SCORE>
  2851. <FOLLOW_UP_QUESTIONS>
  2852. Q_1. Rate this tweet anyway.
  2853. Q_2. N/A
  2854. Q_3. N/A
  2855. </FOLLOW_UP_QUESTIONS>`;
  2856. indicatorInstance.updateInitialReviewAndBuildHistory({
  2857. fullContext: tweetText,
  2858. mediaUrls: mediaUrls,
  2859. apiResponseContent: blacklistResponse,
  2860. reviewSystemPrompt: REVIEW_SYSTEM_PROMPT, // Assumed global
  2861. followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT // Assumed global
  2862. });
  2863. tweetCache.set(tweetId, {
  2864. score: 10,
  2865. description: indicatorInstance.description,
  2866. reasoning: "",
  2867. questions: indicatorInstance.questions,
  2868. lastAnswer: "",
  2869. tweetContent: tweetText,
  2870. mediaUrls: mediaUrls,
  2871. streaming: false,
  2872. blacklisted: true,
  2873. timestamp: Date.now(),
  2874. qaConversationHistory: indicatorInstance.qaConversationHistory
  2875. });
  2876. } else {
  2877. tweetArticle.dataset.sloppinessScore = '10';
  2878. tweetArticle.dataset.blacklisted = 'true';
  2879. tweetArticle.dataset.ratingStatus = 'blacklisted';
  2880. tweetArticle.dataset.ratingDescription = 'User is blacklisted';
  2881. }
  2882. filterSingleTweet(tweetArticle);
  2883. return true;
  2884. }
  2885. const cachedRating = tweetCache.get(tweetId);
  2886. if (cachedRating) {
  2887. if (cachedRating.streaming === true &&
  2888. (cachedRating.score === undefined || cachedRating.score === null)) {
  2889. return false;
  2890. }
  2891. if (cachedRating.score !== undefined && cachedRating.score !== null) {
  2892. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle);
  2893. if (indicatorInstance) {
  2894. indicatorInstance.rehydrateFromCache(cachedRating);
  2895. } else {
  2896. return false;
  2897. }
  2898. filterSingleTweet(tweetArticle);
  2899. return true;
  2900. } else if (!cachedRating.streaming) {
  2901. tweetCache.delete(tweetId);
  2902. return false;
  2903. }
  2904. }
  2905. return false;
  2906. }
  2907. function isUserBlacklisted(handle) {
  2908. if (!handle) return false;
  2909. handle = handle.toLowerCase().trim();
  2910. return blacklistedHandles.some(h => h.toLowerCase().trim() === handle);
  2911. }
  2912. const VALID_FINAL_STATES = ['rated', 'cached', 'blacklisted'];
  2913. const VALID_INTERIM_STATES = ['pending', 'streaming'];
  2914. function isValidFinalState(status) {
  2915. return VALID_FINAL_STATES.includes(status);
  2916. }
  2917. function isValidInterimState(status) {
  2918. return VALID_INTERIM_STATES.includes(status);
  2919. }
  2920. async function delayedProcessTweet(tweetArticle, tweetId, authorHandle) {
  2921. let processingSuccessful = false;
  2922. try {
  2923. const apiKey = browserGet('openrouter-api-key', '');
  2924. if (!apiKey) {
  2925. tweetArticle.dataset.ratingStatus = 'error';
  2926. tweetArticle.dataset.ratingDescription = "No API key";
  2927. ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({
  2928. status: 'error',
  2929. score: 9,
  2930. description: "No API key",
  2931. questions: [],
  2932. lastAnswer: ""
  2933. });
  2934. filterSingleTweet(tweetArticle);
  2935. return;
  2936. }
  2937. if (authorHandle && adAuthorCache.has(authorHandle)) {
  2938. tweetArticle.dataset.ratingStatus = 'rated';
  2939. tweetArticle.dataset.ratingDescription = "Advertisement";
  2940. tweetArticle.dataset.sloppinessScore = '0';
  2941. ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({
  2942. status: 'rated',
  2943. score: 0,
  2944. description: "Advertisement from known ad author",
  2945. questions: [],
  2946. lastAnswer: ""
  2947. });
  2948. filterSingleTweet(tweetArticle);
  2949. processingSuccessful = true;
  2950. return;
  2951. }
  2952. if (isAd(tweetArticle)) {
  2953. if (authorHandle) {
  2954. adAuthorCache.add(authorHandle);
  2955. }
  2956. tweetArticle.dataset.ratingStatus = 'rated';
  2957. tweetArticle.dataset.ratingDescription = "Advertisement";
  2958. tweetArticle.dataset.sloppinessScore = '0';
  2959. ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({
  2960. status: 'rated',
  2961. score: 0,
  2962. description: "Advertisement",
  2963. questions: [],
  2964. lastAnswer: ""
  2965. });
  2966. filterSingleTweet(tweetArticle);
  2967. processingSuccessful = true;
  2968. return;
  2969. }
  2970. let score = 5;
  2971. let description = "";
  2972. let reasoning = "";
  2973. let questions = [];
  2974. let lastAnswer = "";
  2975. try {
  2976. const cachedRating = tweetCache.get(tweetId);
  2977. if (cachedRating) {
  2978. if (cachedRating.streaming === true &&
  2979. (cachedRating.score === undefined || cachedRating.score === null)) {
  2980. }
  2981. else if (!cachedRating.streaming && (cachedRating.score === undefined || cachedRating.score === null)) {
  2982. tweetCache.delete(tweetId);
  2983. }
  2984. }
  2985. const fullContextWithImageDescription = await getFullContext(tweetArticle, tweetId, apiKey);
  2986. if (!fullContextWithImageDescription) {
  2987. throw new Error("Failed to get tweet context");
  2988. }
  2989. let mediaURLs = [];
  2990. if (document.querySelector('div[aria-label="Timeline: Conversation"]')) {
  2991. const replyInfo = getTweetReplyInfo(tweetId);
  2992. if (replyInfo && replyInfo.replyTo) {
  2993. if (!tweetCache.has(tweetId)) {
  2994. tweetCache.set(tweetId, {});
  2995. }
  2996. if (!tweetCache.get(tweetId).threadContext) {
  2997. tweetCache.get(tweetId).threadContext = {
  2998. replyTo: replyInfo.to,
  2999. replyToId: replyInfo.replyTo,
  3000. isRoot: false
  3001. };
  3002. }
  3003. }
  3004. }
  3005. const mediaMatches1 = fullContextWithImageDescription.matchAll(/(?:\[MEDIA_URLS\]:\s*\n)(.*?)(?:\n|$)/g);
  3006. const mediaMatches2 = fullContextWithImageDescription.matchAll(/(?:\[QUOTED_TWEET_MEDIA_URLS\]:\s*\n)(.*?)(?:\n|$)/g);
  3007. for (const match of mediaMatches1) {
  3008. if (match[1]) {
  3009. mediaURLs.push(...match[1].split(', ').filter(url => url.trim()));
  3010. }
  3011. }
  3012. for (const match of mediaMatches2) {
  3013. if (match[1]) {
  3014. mediaURLs.push(...match[1].split(', ').filter(url => url.trim()));
  3015. }
  3016. }
  3017. mediaURLs = [...new Set(mediaURLs.filter(url => url.trim()))];
  3018. if (fullContextWithImageDescription) {
  3019. try {
  3020. const currentCache = tweetCache.get(tweetId);
  3021. const isCached = currentCache &&
  3022. !currentCache.streaming &&
  3023. currentCache.score !== undefined &&
  3024. currentCache.score !== null;
  3025. if (isCached) {
  3026. score = currentCache.score;
  3027. description = currentCache.description || "";
  3028. reasoning = currentCache.reasoning || "";
  3029. questions = currentCache.questions || [];
  3030. lastAnswer = currentCache.lastAnswer || "";
  3031. const mediaUrls = currentCache.mediaUrls || [];
  3032. processingSuccessful = true;
  3033. ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({
  3034. status: currentCache.fromStorage ? 'cached' : 'rated',
  3035. score: score,
  3036. description: description,
  3037. reasoning: reasoning,
  3038. questions: questions,
  3039. lastAnswer: lastAnswer,
  3040. metadata: currentCache.metadata || null,
  3041. mediaUrls: mediaUrls // Pass mediaUrls to indicator
  3042. });
  3043. filterSingleTweet(tweetArticle);
  3044. return;
  3045. }
  3046. const rating = await rateTweetWithOpenRouter(fullContextWithImageDescription, tweetId, apiKey, mediaURLs, 3, tweetArticle, authorHandle);
  3047. score = rating.score;
  3048. description = rating.content;
  3049. reasoning = rating.reasoning || '';
  3050. questions = rating.questions || [];
  3051. lastAnswer = "";
  3052. let finalStatus = rating.error ? 'error' : 'rated';
  3053. if (!rating.error) {
  3054. const cacheEntry = tweetCache.get(tweetId);
  3055. if (cacheEntry && cacheEntry.fromStorage) {
  3056. finalStatus = 'cached';
  3057. } else if (rating.cached) {
  3058. finalStatus = 'cached';
  3059. }
  3060. }
  3061. tweetArticle.dataset.ratingStatus = finalStatus;
  3062. tweetArticle.dataset.ratingDescription = description || "not available";
  3063. tweetArticle.dataset.sloppinessScore = score?.toString() || '';
  3064. tweetArticle.dataset.ratingReasoning = reasoning;
  3065. ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({
  3066. status: finalStatus,
  3067. score: score,
  3068. description: description,
  3069. reasoning: reasoning,
  3070. questions: questions,
  3071. lastAnswer: lastAnswer,
  3072. metadata: rating.data?.id ? { generationId: rating.data.id } : null, // Pass metadata
  3073. mediaUrls: mediaURLs // Pass mediaUrls to indicator
  3074. });
  3075. processingSuccessful = !rating.error;
  3076. filterSingleTweet(tweetArticle);
  3077. return;
  3078. } catch (apiError) {
  3079. score = 5;
  3080. description = `API Error: ${apiError.message}`;
  3081. reasoning = '';
  3082. questions = [];
  3083. lastAnswer = '';
  3084. processingSuccessful = false;
  3085. ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({
  3086. status: 'error',
  3087. score: score,
  3088. description: description,
  3089. questions: [],
  3090. lastAnswer: ""
  3091. });
  3092. const errorCacheEntry = tweetCache.get(tweetId) || {};
  3093. errorCacheEntry.score = score;
  3094. errorCacheEntry.description = description;
  3095. errorCacheEntry.reasoning = reasoning;
  3096. errorCacheEntry.questions = questions;
  3097. errorCacheEntry.lastAnswer = lastAnswer;
  3098. errorCacheEntry.error = true;
  3099. errorCacheEntry.streaming = false;
  3100. tweetCache.set(tweetId, errorCacheEntry);
  3101. filterSingleTweet(tweetArticle);
  3102. return;
  3103. }
  3104. }
  3105. filterSingleTweet(tweetArticle);
  3106. } catch (error) {
  3107. ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({
  3108. status: 'error',
  3109. score: 5,
  3110. description: "Error during processing: " + error.message,
  3111. questions: [],
  3112. lastAnswer: ""
  3113. });
  3114. filterSingleTweet(tweetArticle);
  3115. processingSuccessful = false;
  3116. } finally {
  3117. if (!processingSuccessful) {
  3118. processedTweets.delete(tweetId);
  3119. }
  3120. }
  3121. } catch (error) {
  3122. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId);
  3123. if (indicatorInstance) {
  3124. indicatorInstance.update({
  3125. status: 'error',
  3126. score: 5,
  3127. description: "Error during processing: " + error.message,
  3128. questions: [],
  3129. lastAnswer: ""
  3130. });
  3131. }
  3132. filterSingleTweet(tweetArticle);
  3133. processingSuccessful = false;
  3134. } finally {
  3135. if (!processingSuccessful) {
  3136. processedTweets.delete(tweetId);
  3137. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId);
  3138. if (indicatorInstance && !isValidFinalState(indicatorInstance.status)) {
  3139. setTimeout(() => {
  3140. if (!isValidFinalState(ScoreIndicatorRegistry.get(tweetId)?.status)) {
  3141. scheduleTweetProcessing(tweetArticle);
  3142. }
  3143. }, PROCESSING_DELAY_MS * 2);
  3144. }
  3145. }
  3146. }
  3147. }
  3148. async function scheduleTweetProcessing(tweetArticle) {
  3149. const tweetId = getTweetID(tweetArticle);
  3150. if (!tweetId) {
  3151. return;
  3152. }
  3153. if (window.activeStreamingRequests && window.activeStreamingRequests[tweetId]) {
  3154. return;
  3155. }
  3156. const handles = getUserHandles(tweetArticle);
  3157. const authorHandle = handles.length > 0 ? handles[0] : '';
  3158. if (authorHandle && adAuthorCache.has(authorHandle)) {
  3159. filterSingleTweet(tweetArticle);
  3160. return;
  3161. }
  3162. if (isAd(tweetArticle)) {
  3163. if (authorHandle) {
  3164. adAuthorCache.add(authorHandle);
  3165. }
  3166. filterSingleTweet(tweetArticle);
  3167. return;
  3168. }
  3169. const existingInstance = ScoreIndicatorRegistry.get(tweetId);
  3170. if (existingInstance) {
  3171. existingInstance.ensureIndicatorAttached();
  3172. if (isValidFinalState(existingInstance.status)) {
  3173. filterSingleTweet(tweetArticle);
  3174. return;
  3175. }
  3176. if (isValidInterimState(existingInstance.status) && processedTweets.has(tweetId)) {
  3177. filterSingleTweet(tweetArticle);
  3178. return;
  3179. }
  3180. processedTweets.delete(tweetId);
  3181. }
  3182. if (authorHandle && isUserBlacklisted(authorHandle)) {
  3183. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle);
  3184. if (indicatorInstance) {
  3185. const tweetText = getElementText(tweetArticle.querySelector(TWEET_TEXT_SELECTOR)) || "[Tweet text not found]";
  3186. const mediaUrls = await extractMediaLinks(tweetArticle);
  3187. const blacklistResponse = `<ANALYSIS>
  3188. This user is on the blacklist. Tweets from this user are not rated by the AI and are always shown.
  3189. </ANALYSIS>
  3190. <SCORE>
  3191. SCORE_10
  3192. </SCORE>
  3193. <FOLLOW_UP_QUESTIONS>
  3194. Q_1. Rate this tweet anyway.
  3195. Q_2. N/A
  3196. Q_3. N/A
  3197. </FOLLOW_UP_QUESTIONS>`;
  3198. indicatorInstance.updateInitialReviewAndBuildHistory({
  3199. fullContext: tweetText,
  3200. mediaUrls: mediaUrls,
  3201. apiResponseContent: blacklistResponse,
  3202. reviewSystemPrompt: REVIEW_SYSTEM_PROMPT, // Assumed global
  3203. followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT // Assumed global
  3204. });
  3205. tweetCache.set(tweetId, {
  3206. score: 10,
  3207. description: indicatorInstance.description,
  3208. reasoning: "",
  3209. questions: indicatorInstance.questions,
  3210. lastAnswer: "",
  3211. tweetContent: tweetText,
  3212. mediaUrls: mediaUrls,
  3213. streaming: false,
  3214. blacklisted: true,
  3215. timestamp: Date.now(),
  3216. qaConversationHistory: indicatorInstance.qaConversationHistory
  3217. });
  3218. } else {
  3219. tweetArticle.dataset.sloppinessScore = '10';
  3220. tweetArticle.dataset.blacklisted = 'true';
  3221. tweetArticle.dataset.ratingStatus = 'blacklisted';
  3222. tweetArticle.dataset.ratingDescription = 'User is blacklisted';
  3223. }
  3224. filterSingleTweet(tweetArticle);
  3225. return;
  3226. }
  3227. if (tweetCache.has(tweetId)) {
  3228. const isIncompleteStreaming =
  3229. tweetCache.get(tweetId).streaming === true &&
  3230. (tweetCache.get(tweetId).score === undefined || tweetCache.get(tweetId).score === null);
  3231. if (!isIncompleteStreaming) {
  3232. const wasApplied = await applyTweetCachedRating(tweetArticle);
  3233. if (wasApplied) {
  3234. return;
  3235. }
  3236. }
  3237. }
  3238. if (processedTweets.has(tweetId)) {
  3239. const instance = ScoreIndicatorRegistry.get(tweetId);
  3240. if (instance) {
  3241. instance.ensureIndicatorAttached();
  3242. if (instance.status === 'pending' || instance.status === 'streaming') {
  3243. filterSingleTweet(tweetArticle);
  3244. return;
  3245. }
  3246. }
  3247. processedTweets.delete(tweetId);
  3248. }
  3249. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle);
  3250. if (indicatorInstance) {
  3251. if (indicatorInstance.status !== 'blacklisted' &&
  3252. indicatorInstance.status !== 'cached' &&
  3253. indicatorInstance.status !== 'rated') {
  3254. indicatorInstance.update({ status: 'pending', score: null, description: 'Rating scheduled...', questions: [], lastAnswer: "" });
  3255. } else {
  3256. indicatorInstance.ensureIndicatorAttached();
  3257. filterSingleTweet(tweetArticle);
  3258. return;
  3259. }
  3260. } else {
  3261. }
  3262. if (!processedTweets.has(tweetId)) {
  3263. processedTweets.add(tweetId);
  3264. }
  3265. setTimeout(() => {
  3266. try {
  3267. delayedProcessTweet(tweetArticle, tweetId, authorHandle);
  3268. } catch (e) {
  3269. processedTweets.delete(tweetId);
  3270. }
  3271. }, PROCESSING_DELAY_MS);
  3272. }
  3273. let threadRelationships = {};
  3274. const THREAD_CHECK_INTERVAL = 2500;
  3275. const SWEEP_INTERVAL = 1000;
  3276. let threadMappingInProgress = false;
  3277. function loadThreadRelationships() {
  3278. try {
  3279. const savedRelationships = browserGet('threadRelationships', '{}');
  3280. threadRelationships = JSON.parse(savedRelationships);
  3281. } catch (e) {
  3282. threadRelationships = {};
  3283. }
  3284. }
  3285. function saveThreadRelationships() {
  3286. try {
  3287. const relationshipCount = Object.keys(threadRelationships).length;
  3288. if (relationshipCount > 1000) {
  3289. const entries = Object.entries(threadRelationships);
  3290. entries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0));
  3291. const recent = entries.slice(0, 500);
  3292. threadRelationships = Object.fromEntries(recent);
  3293. }
  3294. browserSet('threadRelationships', JSON.stringify(threadRelationships));
  3295. } catch (e) {
  3296. }
  3297. }
  3298. loadThreadRelationships();
  3299. async function buildReplyChain(tweetId, maxDepth = 5) {
  3300. if (!tweetId || maxDepth <= 0) return [];
  3301. const chain = [];
  3302. let currentId = tweetId;
  3303. let depth = 0;
  3304. while (currentId && depth < maxDepth) {
  3305. const replyInfo = threadRelationships[currentId];
  3306. if (!replyInfo || !replyInfo.replyTo) break;
  3307. chain.push({
  3308. fromId: currentId,
  3309. toId: replyInfo.replyTo,
  3310. from: replyInfo.from,
  3311. to: replyInfo.to
  3312. });
  3313. currentId = replyInfo.replyTo;
  3314. depth++;
  3315. }
  3316. return chain;
  3317. }
  3318. async function getFullContext(tweetArticle, tweetId, apiKey) {
  3319. const handles = getUserHandles(tweetArticle);
  3320. const userHandle = handles.length > 0 ? handles[0] : '';
  3321. const quotedHandle = handles.length > 1 ? handles[1] : '';
  3322. const mainText = getElementText(tweetArticle.querySelector(TWEET_TEXT_SELECTOR));
  3323. let allMediaLinks = await extractMediaLinks(tweetArticle);
  3324. let quotedText = "";
  3325. let quotedMediaLinks = [];
  3326. let quotedTweetId = null;
  3327. const quoteContainer = tweetArticle.querySelector(QUOTE_CONTAINER_SELECTOR);
  3328. if (quoteContainer) {
  3329. const quotedLink = quoteContainer.querySelector('a[href*="/status/"]');
  3330. if (quotedLink) {
  3331. const href = quotedLink.getAttribute('href');
  3332. const match = href.match(/\/status\/(\d+)/);
  3333. if (match && match[1]) {
  3334. quotedTweetId = match[1];
  3335. }
  3336. }
  3337. quotedText = getElementText(quoteContainer.querySelector(TWEET_TEXT_SELECTOR)) || "";
  3338. quotedMediaLinks = await extractMediaLinks(quoteContainer);
  3339. }
  3340. const conversation = document.querySelector('div[aria-label="Timeline: Conversation"]') ||
  3341. document.querySelector('div[aria-label^="Timeline: Conversation"]');
  3342. let threadMediaUrls = [];
  3343. if (conversation && conversation.dataset.threadMapping && tweetCache.has(tweetId) && tweetCache.get(tweetId).threadContext?.threadMediaUrls) {
  3344. threadMediaUrls = tweetCache.get(tweetId).threadContext.threadMediaUrls || [];
  3345. } else if (conversation && conversation.dataset.threadMediaUrls) {
  3346. try {
  3347. const allMediaUrls = JSON.parse(conversation.dataset.threadMediaUrls);
  3348. threadMediaUrls = Array.isArray(allMediaUrls) ? allMediaUrls : [];
  3349. } catch (e) {
  3350. }
  3351. }
  3352. let allAvailableMediaLinks = [...allMediaLinks];
  3353. let mainMediaLinks = allAvailableMediaLinks.filter(link => !quotedMediaLinks.includes(link));
  3354. let engagementStats = "";
  3355. const engagementDiv = tweetArticle.querySelector('div[role="group"][aria-label$=" views"]');
  3356. if (engagementDiv) {
  3357. engagementStats = engagementDiv.getAttribute('aria-label')?.trim() || "";
  3358. }
  3359. let fullContextWithImageDescription = `[TWEET ${tweetId}]
  3360. Author:@${userHandle}:
  3361. ` + mainText;
  3362. if (mainMediaLinks.length > 0) {
  3363. if (enableImageDescriptions = browserGet('enableImageDescriptions', false)) {
  3364. let mainMediaLinksDescription = await getImageDescription(mainMediaLinks, apiKey, tweetId, userHandle);
  3365. fullContextWithImageDescription += `
  3366. [MEDIA_DESCRIPTION]:
  3367. ${mainMediaLinksDescription}`;
  3368. }
  3369. fullContextWithImageDescription += `
  3370. [MEDIA_URLS]:
  3371. ${mainMediaLinks.join(", ")}`;
  3372. }
  3373. if (engagementStats) {
  3374. fullContextWithImageDescription += `
  3375. [ENGAGEMENT_STATS]:
  3376. ${engagementStats}`;
  3377. }
  3378. if (!isOriginalTweet(tweetArticle) && threadMediaUrls.length > 0) {
  3379. const uniqueThreadMediaUrls = threadMediaUrls.filter(url =>
  3380. !mainMediaLinks.includes(url) && !quotedMediaLinks.includes(url));
  3381. if (uniqueThreadMediaUrls.length > 0) {
  3382. fullContextWithImageDescription += `
  3383. [THREAD_MEDIA_URLS]:
  3384. ${uniqueThreadMediaUrls.join(", ")}`;
  3385. }
  3386. }
  3387. if (quotedText || quotedMediaLinks.length > 0) {
  3388. fullContextWithImageDescription += `
  3389. [QUOTED_TWEET${quotedTweetId ? ' ' + quotedTweetId : ''}]:
  3390. Author:@${quotedHandle}:
  3391. ${quotedText}`;
  3392. if (quotedMediaLinks.length > 0) {
  3393. if (enableImageDescriptions) {
  3394. let quotedMediaLinksDescription = await getImageDescription(quotedMediaLinks, apiKey, tweetId, userHandle);
  3395. fullContextWithImageDescription += `
  3396. [QUOTED_TWEET_MEDIA_DESCRIPTION]:
  3397. ${quotedMediaLinksDescription}`;
  3398. }
  3399. fullContextWithImageDescription += `
  3400. [QUOTED_TWEET_MEDIA_URLS]:
  3401. ${quotedMediaLinks.join(", ")}`;
  3402. }
  3403. }
  3404. if (document.querySelector('div[aria-label="Timeline: Conversation"]', 'div[aria-label^="Timeline: Conversation"]')) {
  3405. const replyChain = await buildReplyChain(tweetId);
  3406. let threadHistoryIncluded = false;
  3407. if (conversation && conversation.dataset.threadHist) {
  3408. if (!isOriginalTweet(tweetArticle)) {
  3409. fullContextWithImageDescription = conversation.dataset.threadHist + `
  3410. [REPLY]
  3411. ` + fullContextWithImageDescription;
  3412. threadHistoryIncluded = true;
  3413. }
  3414. }
  3415. if (replyChain.length > 0 && !threadHistoryIncluded) {
  3416. let parentContexts = "";
  3417. for (let i = replyChain.length - 1; i >= 0; i--) {
  3418. const link = replyChain[i];
  3419. const parentId = link.toId;
  3420. const parentCache = tweetCache.get(parentId);
  3421. const parentContent = parentCache?.tweetContent;
  3422. if (parentContent) {
  3423. parentContexts = parentContent + "\n[REPLY]\n" + parentContexts;
  3424. } else {
  3425. parentContexts = `[CONTEXT UNAVAILABLE FOR TWEET ${parentId} @${link.to || 'unknown'}]\n[REPLY]\n` + parentContexts;
  3426. }
  3427. }
  3428. fullContextWithImageDescription = parentContexts + fullContextWithImageDescription;
  3429. }
  3430. const replyInfo = getTweetReplyInfo(tweetId);
  3431. if (replyInfo && replyInfo.replyTo && !threadHistoryIncluded && replyChain.length === 0) {
  3432. fullContextWithImageDescription = `[REPLY TO TWEET ${replyInfo.replyTo}]\n` + fullContextWithImageDescription;
  3433. }
  3434. }
  3435. tweetArticle.dataset.fullContext = fullContextWithImageDescription;
  3436. return fullContextWithImageDescription;
  3437. }
  3438. function applyFilteringToAll() {
  3439. if (!observedTargetNode) return;
  3440. const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR);
  3441. tweets.forEach(filterSingleTweet);
  3442. }
  3443. function ensureAllTweetsRated() {
  3444. if (!observedTargetNode) return;
  3445. const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR);
  3446. if (tweets.length > 0) {
  3447. tweets.forEach(tweet => {
  3448. const tweetId = getTweetID(tweet);
  3449. if (!tweetId) return;
  3450. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId);
  3451. const needsProcessing = !indicatorInstance ||
  3452. !indicatorInstance.status ||
  3453. indicatorInstance.status === 'error' ||
  3454. (!isValidFinalState(indicatorInstance.status) && !isValidInterimState(indicatorInstance.status)) ||
  3455. (processedTweets.has(tweetId) && !isValidFinalState(indicatorInstance.status) && !isValidInterimState(indicatorInstance.status));
  3456. if (needsProcessing) {
  3457. if (processedTweets.has(tweetId)) {
  3458. processedTweets.delete(tweetId);
  3459. }
  3460. scheduleTweetProcessing(tweet);
  3461. } else if (indicatorInstance && !isValidInterimState(indicatorInstance.status)) {
  3462. filterSingleTweet(tweet);
  3463. }
  3464. });
  3465. }
  3466. }
  3467. async function handleThreads() {
  3468. try {
  3469. let conversation = document.querySelector('div[aria-label="Timeline: Conversation"]');
  3470. if (!conversation) {
  3471. conversation = document.querySelector('div[aria-label^="Timeline: Conversation"]');
  3472. }
  3473. if (!conversation) return;
  3474. if (threadMappingInProgress || conversation.dataset.threadHist === "pending") {
  3475. return;
  3476. }
  3477. if (conversation.dataset.threadMappedAt) {
  3478. const lastMappedTime = parseInt(conversation.dataset.threadMappedAt, 10);
  3479. if (Date.now() - lastMappedTime < 10000) {
  3480. return;
  3481. }
  3482. }
  3483. const match = location.pathname.match(/status\/(\d+)/);
  3484. const pageTweetId = match ? match[1] : null;
  3485. if (!pageTweetId) return;
  3486. let rootTweetId = pageTweetId;
  3487. while (threadRelationships[rootTweetId] && threadRelationships[rootTweetId].replyTo) {
  3488. rootTweetId = threadRelationships[rootTweetId].replyTo;
  3489. }
  3490. if (conversation.dataset.threadHist === undefined) {
  3491. threadHist = "";
  3492. const rootArticle = Array.from(conversation.querySelectorAll('article[data-testid="tweet"]'))
  3493. .find(el => getTweetID(el) === rootTweetId)
  3494. || document.querySelector('article[data-testid="tweet"]');
  3495. if (rootArticle) {
  3496. conversation.dataset.threadHist = 'pending';
  3497. threadMappingInProgress = true;
  3498. try {
  3499. const tweetId = getTweetID(rootArticle);
  3500. if (!tweetId) {
  3501. throw new Error("Failed to get tweet ID from first article");
  3502. }
  3503. const apiKey = browserGet('openrouter-api-key', '');
  3504. const fullcxt = await getFullContext(rootArticle, tweetId, apiKey);
  3505. if (!fullcxt) {
  3506. throw new Error("Failed to get full context for root tweet");
  3507. }
  3508. threadHist = fullcxt;
  3509. conversation.dataset.threadHist = threadHist;
  3510. if (conversation.firstChild) {
  3511. conversation.firstChild.dataset.canary = "true";
  3512. }
  3513. if (!processedTweets.has(tweetId)) {
  3514. scheduleTweetProcessing(rootArticle);
  3515. }
  3516. setTimeout(() => {
  3517. mapThreadStructure(conversation, rootTweetId);
  3518. }, 10);
  3519. } catch (error) {
  3520. threadMappingInProgress = false;
  3521. delete conversation.dataset.threadHist;
  3522. }
  3523. return;
  3524. }
  3525. } else if (conversation.dataset.threadHist !== "pending" &&
  3526. conversation.firstChild &&
  3527. conversation.firstChild.dataset.canary === undefined) {
  3528. if (conversation.firstChild) {
  3529. conversation.firstChild.dataset.canary = "pending";
  3530. }
  3531. threadMappingInProgress = true;
  3532. try {
  3533. const nextArticle = document.querySelector('article[data-testid="tweet"]:has(~ div[data-testid="inline_reply_offscreen"])');
  3534. if (nextArticle) {
  3535. const tweetId = getTweetID(nextArticle);
  3536. if (!tweetId) {
  3537. throw new Error("Failed to get tweet ID from next article");
  3538. }
  3539. if (tweetCache.has(tweetId) && tweetCache.get(tweetId).tweetContent) {
  3540. threadHist = threadHist + "\n[REPLY]\n" + tweetCache.get(tweetId).tweetContent;
  3541. } else {
  3542. const apiKey = browserGet('openrouter-api-key', '');
  3543. await new Promise(resolve => setTimeout(resolve, 10));
  3544. const newContext = await getFullContext(nextArticle, tweetId, apiKey);
  3545. if (!newContext) {
  3546. throw new Error("Failed to get context for next article");
  3547. }
  3548. threadHist = threadHist + "\n[REPLY]\n" + newContext;
  3549. }
  3550. conversation.dataset.threadHist = threadHist;
  3551. }
  3552. setTimeout(() => {
  3553. mapThreadStructure(conversation, rootTweetId);
  3554. }, 500);
  3555. } catch (error) {
  3556. threadMappingInProgress = false;
  3557. if (conversation.firstChild) {
  3558. delete conversation.firstChild.dataset.canary;
  3559. }
  3560. }
  3561. } else if (!threadMappingInProgress && !conversation.dataset.threadMappingInProgress) {
  3562. threadMappingInProgress = true;
  3563. setTimeout(() => {
  3564. mapThreadStructure(conversation, rootTweetId);
  3565. }, 250);
  3566. }
  3567. } catch (error) {
  3568. threadMappingInProgress = false;
  3569. }
  3570. }
  3571. async function mapThreadStructure(conversation, localRootTweetId) {
  3572. conversation.dataset.threadMappingInProgress = "true";
  3573. conversation.dataset.threadMappedAt = Date.now().toString();
  3574. threadMappingInProgress = true;
  3575. try {
  3576. const timeout = new Promise((_, reject) =>
  3577. setTimeout(() => reject(new Error('Thread mapping timed out')), 5000)
  3578. );
  3579. const mapping = async () => {
  3580. let cellDivs = Array.from(document.querySelectorAll('div[data-testid="cellInnerDiv"]'));
  3581. if (!cellDivs.length) {
  3582. delete conversation.dataset.threadMappingInProgress;
  3583. threadMappingInProgress = false;
  3584. return;
  3585. }
  3586. let tweetCells = [];
  3587. let processedCount = 0;
  3588. for (let idx = 0; idx < cellDivs.length; idx++) {
  3589. const cell = cellDivs[idx];
  3590. const article = cell.querySelector('article[data-testid="tweet"]');
  3591. if (!article) continue;
  3592. try {
  3593. let tweetId = getTweetID(article);
  3594. if (!tweetId) {
  3595. let tweetLink = article.querySelector('a[href*="/status/"]');
  3596. if (tweetLink) {
  3597. let match = tweetLink.href.match(/status\/(\d+)/);
  3598. if (match) tweetId = match[1];
  3599. }
  3600. }
  3601. if (!tweetId) continue;
  3602. const handles = getUserHandles(article);
  3603. let username = handles.length > 0 ? handles[0] : null;
  3604. if (!username) continue;
  3605. let tweetTextSpan = article.querySelector('[data-testid="tweetText"]');
  3606. let text = tweetTextSpan ? tweetTextSpan.innerText.trim().replace(/\n+/g, ' ⏎ ') : '';
  3607. let mediaLinks = await extractMediaLinks(article);
  3608. let quotedMediaLinks = [];
  3609. const quoteContainer = article.querySelector(QUOTE_CONTAINER_SELECTOR);
  3610. if (quoteContainer) {
  3611. quotedMediaLinks = await extractMediaLinks(quoteContainer);
  3612. }
  3613. let prevCell = cellDivs[idx - 1] || null;
  3614. let isReplyToRoot = false;
  3615. if (prevCell && prevCell.childElementCount === 1) {
  3616. let onlyChild = prevCell.children[0];
  3617. if (onlyChild && onlyChild.children.length === 0 && onlyChild.innerHTML.trim() === '') {
  3618. isReplyToRoot = true;
  3619. }
  3620. }
  3621. tweetCells.push({
  3622. tweetNode: article,
  3623. username,
  3624. tweetId,
  3625. text,
  3626. mediaLinks,
  3627. quotedMediaLinks,
  3628. cellIndex: idx,
  3629. isReplyToRoot,
  3630. cellDiv: cell,
  3631. index: processedCount++
  3632. });
  3633. if (!processedTweets.has(tweetId)) {
  3634. scheduleTweetProcessing(article);
  3635. }
  3636. } catch (err) {
  3637. continue;
  3638. }
  3639. }
  3640. if (tweetCells.length === 0) {
  3641. delete conversation.dataset.threadMappingInProgress;
  3642. threadMappingInProgress = false;
  3643. return;
  3644. }
  3645. for (let i = 0; i < tweetCells.length; ++i) {
  3646. let tw = tweetCells[i];
  3647. const persistentRelation = threadRelationships[tw.tweetId];
  3648. if (tw.tweetId === localRootTweetId) {
  3649. tw.replyTo = null;
  3650. tw.replyToId = null;
  3651. tw.isRoot = true;
  3652. } else if (persistentRelation && persistentRelation.replyTo) {
  3653. tw.replyTo = persistentRelation.to;
  3654. tw.replyToId = persistentRelation.replyTo;
  3655. tw.isRoot = false;
  3656. } else if (tw.isReplyToRoot) {
  3657. let root = tweetCells.find(tk => tk.tweetId === localRootTweetId);
  3658. tw.replyTo = root ? root.username : null;
  3659. tw.replyToId = root ? root.tweetId : null;
  3660. tw.isRoot = false;
  3661. } else if (i > 0) {
  3662. tw.replyTo = tweetCells[i - 1].username;
  3663. tw.replyToId = tweetCells[i - 1].tweetId;
  3664. tw.isRoot = false;
  3665. } else {
  3666. tw.replyTo = null;
  3667. tw.replyToId = null;
  3668. tw.isRoot = false;
  3669. }
  3670. }
  3671. const replyDocs = tweetCells.map(tw => ({
  3672. from: tw.username,
  3673. tweetId: tw.tweetId,
  3674. to: tw.replyTo,
  3675. toId: tw.replyToId,
  3676. isRoot: tw.isRoot === true,
  3677. text: tw.text,
  3678. mediaLinks: tw.mediaLinks || [],
  3679. quotedMediaLinks: tw.quotedMediaLinks || []
  3680. }));
  3681. for (let tw of tweetCells) {
  3682. if (!tw.replyToId && !tw.isRoot && threadRelationships[tw.tweetId]?.replyTo) {
  3683. tw.replyToId = threadRelationships[tw.tweetId].replyTo;
  3684. tw.replyTo = threadRelationships[tw.tweetId].to;
  3685. const doc = replyDocs.find(d => d.tweetId === tw.tweetId);
  3686. if (doc) {
  3687. doc.toId = tw.replyToId;
  3688. doc.to = tw.replyTo;
  3689. }
  3690. }
  3691. }
  3692. conversation.dataset.threadMapping = JSON.stringify(replyDocs);
  3693. const timestamp = Date.now();
  3694. replyDocs.forEach(doc => {
  3695. if (doc.tweetId && doc.toId) {
  3696. threadRelationships[doc.tweetId] = {
  3697. replyTo: doc.toId,
  3698. from: doc.from,
  3699. to: doc.to,
  3700. isRoot: false,
  3701. timestamp
  3702. };
  3703. } else if (doc.tweetId && doc.isRoot) {
  3704. threadRelationships[doc.tweetId] = {
  3705. replyTo: null,
  3706. from: doc.from,
  3707. isRoot: true,
  3708. timestamp
  3709. };
  3710. }
  3711. });
  3712. saveThreadRelationships();
  3713. let completeThreadHistory = "";
  3714. const rootTweet = replyDocs.find(t => t.isRoot === true);
  3715. if (rootTweet && rootTweet.tweetId) {
  3716. const rootTweetElement = tweetCells.find(t => t.tweetId === rootTweet.tweetId)?.tweetNode;
  3717. if (rootTweetElement) {
  3718. try {
  3719. const apiKey = browserGet('openrouter-api-key', '');
  3720. const rootContext = await getFullContext(rootTweetElement, rootTweet.tweetId, apiKey);
  3721. if (rootContext) {
  3722. completeThreadHistory = rootContext;
  3723. conversation.dataset.threadHist = completeThreadHistory;
  3724. const allMediaUrls = [];
  3725. replyDocs.forEach(doc => {
  3726. if (doc.mediaLinks && doc.mediaLinks.length) {
  3727. allMediaUrls.push(...doc.mediaLinks);
  3728. }
  3729. if (doc.quotedMediaLinks && doc.quotedMediaLinks.length) {
  3730. allMediaUrls.push(...doc.quotedMediaLinks);
  3731. }
  3732. });
  3733. if (allMediaUrls.length > 0) {
  3734. conversation.dataset.threadMediaUrls = JSON.stringify(allMediaUrls);
  3735. }
  3736. }
  3737. } catch (error) {
  3738. }
  3739. }
  3740. }
  3741. const batchSize = 10;
  3742. for (let i = 0; i < replyDocs.length; i += batchSize) {
  3743. const batch = replyDocs.slice(i, i + batchSize);
  3744. batch.forEach(doc => {
  3745. if (doc.tweetId && tweetCache.has(doc.tweetId)) {
  3746. tweetCache.get(doc.tweetId).threadContext = {
  3747. replyTo: doc.to,
  3748. replyToId: doc.toId,
  3749. isRoot: doc.isRoot,
  3750. threadMediaUrls: doc.isRoot ? [] : getAllPreviousMediaUrls(doc.tweetId, replyDocs)
  3751. };
  3752. if (doc.tweetId && processedTweets.has(doc.tweetId)) {
  3753. const tweetCell = tweetCells.find(tc => tc.tweetId === doc.tweetId);
  3754. if (tweetCell && tweetCell.tweetNode) {
  3755. const isStreaming = tweetCell.tweetNode.dataset.ratingStatus === 'streaming' ||
  3756. (tweetCache.has(doc.tweetId) && tweetCache.get(doc.tweetId).streaming === true);
  3757. if (!isStreaming) {
  3758. processedTweets.delete(doc.tweetId);
  3759. scheduleTweetProcessing(tweetCell.tweetNode);
  3760. }
  3761. }
  3762. }
  3763. }
  3764. });
  3765. if (i + batchSize < replyDocs.length) {
  3766. await new Promise(resolve => setTimeout(resolve, 0));
  3767. }
  3768. }
  3769. delete conversation.dataset.threadMappingInProgress;
  3770. threadMappingInProgress = false;
  3771. };
  3772. function getAllPreviousMediaUrls(tweetId, replyDocs) {
  3773. const allMediaUrls = [];
  3774. const index = replyDocs.findIndex(doc => doc.tweetId === tweetId);
  3775. if (index > 0) {
  3776. for (let i = 0; i < index; i++) {
  3777. if (replyDocs[i].mediaLinks && replyDocs[i].mediaLinks.length) {
  3778. allMediaUrls.push(...replyDocs[i].mediaLinks);
  3779. }
  3780. if (replyDocs[i].quotedMediaLinks && replyDocs[i].quotedMediaLinks.length) {
  3781. allMediaUrls.push(...replyDocs[i].quotedMediaLinks);
  3782. }
  3783. }
  3784. }
  3785. return allMediaUrls;
  3786. }
  3787. await Promise.race([mapping(), timeout]);
  3788. } catch (error) {
  3789. delete conversation.dataset.threadMappedAt;
  3790. delete conversation.dataset.threadMappingInProgress;
  3791. threadMappingInProgress = false;
  3792. }
  3793. }
  3794. function getTweetReplyInfo(tweetId) {
  3795. if (threadRelationships[tweetId]) {
  3796. return threadRelationships[tweetId];
  3797. }
  3798. return null;
  3799. }
  3800. setInterval(handleThreads, THREAD_CHECK_INTERVAL);
  3801. setInterval(ensureAllTweetsRated, SWEEP_INTERVAL);
  3802. setInterval(applyFilteringToAll, SWEEP_INTERVAL);
  3803. // ----- api/api_requests.js -----
  3804. async function getCompletion(request, apiKey, timeout = 30000) {
  3805. return new Promise((resolve) => {
  3806. GM_xmlhttpRequest({
  3807. method: "POST",
  3808. url: "https://openrouter.ai/api/v1/chat/completions",
  3809. headers: {
  3810. "Content-Type": "application/json",
  3811. "Authorization": `Bearer ${apiKey}`,
  3812. "HTTP-Referer": "https://greasyfork.org/en/scripts/532459-tweetfilter-ai",
  3813. "X-Title": "TweetFilter-AI"
  3814. },
  3815. data: JSON.stringify(request),
  3816. timeout: timeout,
  3817. onload: function (response) {
  3818. if (response.status >= 200 && response.status < 300) {
  3819. try {
  3820. const data = JSON.parse(response.responseText);
  3821. if (data.content==="") {
  3822. resolve({
  3823. error: true,
  3824. message: `No content returned${data.choices[0].native_finish_reason=="SAFETY"?" (SAFETY FILTER)":""}`,
  3825. data: data
  3826. });
  3827. }
  3828. resolve({
  3829. error: false,
  3830. message: "Request successful",
  3831. data: data
  3832. });
  3833. } catch (error) {
  3834. resolve({
  3835. error: true,
  3836. message: `Failed to parse response: ${error.message}`,
  3837. data: null
  3838. });
  3839. }
  3840. } else {
  3841. resolve({
  3842. error: true,
  3843. message: `Request failed with status ${response.status}: ${response.responseText}`,
  3844. data: null
  3845. });
  3846. }
  3847. },
  3848. onerror: function (error) {
  3849. resolve({
  3850. error: true,
  3851. message: `Request error: ${error.toString()}`,
  3852. data: null
  3853. });
  3854. },
  3855. ontimeout: function () {
  3856. resolve({
  3857. error: true,
  3858. message: `Request timed out after ${timeout}ms`,
  3859. data: null
  3860. });
  3861. }
  3862. });
  3863. });
  3864. }
  3865. function getCompletionStreaming(request, apiKey, onChunk, onComplete, onError, timeout = 90000, tweetId = null) {
  3866. const streamingRequest = {
  3867. ...request,
  3868. stream: true
  3869. };
  3870. let fullResponse = "";
  3871. let content = "";
  3872. let reasoning = "";
  3873. let responseObj = null;
  3874. let streamComplete = false;
  3875. const reqObj = GM_xmlhttpRequest({
  3876. method: "POST",
  3877. url: "https://openrouter.ai/api/v1/chat/completions",
  3878. headers: {
  3879. "Content-Type": "application/json",
  3880. "Authorization": `Bearer ${apiKey}`,
  3881. "HTTP-Referer": "https://greasyfork.org/en/scripts/532459-tweetfilter-ai",
  3882. "X-Title": "TweetFilter-AI"
  3883. },
  3884. data: JSON.stringify(streamingRequest),
  3885. timeout: timeout,
  3886. responseType: "stream",
  3887. onloadstart: function(response) {
  3888. const reader = response.response.getReader();
  3889. const resetStreamTimeout = () => {
  3890. if (streamTimeout) clearTimeout(streamTimeout);
  3891. streamTimeout = setTimeout(() => {
  3892. if (!streamComplete) {
  3893. streamComplete = true;
  3894. onComplete({
  3895. content: content,
  3896. reasoning: reasoning,
  3897. fullResponse: fullResponse,
  3898. data: responseObj,
  3899. timedOut: true
  3900. });
  3901. }
  3902. }, 30000);
  3903. };
  3904. let streamTimeout = null;
  3905. const processStream = async () => {
  3906. try {
  3907. resetStreamTimeout()
  3908. let isDone = false;
  3909. let emptyChunksCount = 0;
  3910. while (!isDone && !streamComplete) {
  3911. const { done, value } = await reader.read();
  3912. if (done) {
  3913. isDone = true;
  3914. break;
  3915. }
  3916. const chunk = new TextDecoder().decode(value);
  3917. clearTimeout(streamTimeout);
  3918. resetStreamTimeout();
  3919. if (chunk.trim() === '') {
  3920. emptyChunksCount++;
  3921. if (emptyChunksCount >= 3) {
  3922. isDone = true;
  3923. break;
  3924. }
  3925. continue;
  3926. }
  3927. emptyChunksCount = 0;
  3928. fullResponse += chunk;
  3929. const lines = chunk.split("\n");
  3930. for (const line of lines) {
  3931. if (line.startsWith("data: ")) {
  3932. const data = line.substring(6);
  3933. if (data === "[DONE]") {
  3934. isDone = true;
  3935. break;
  3936. }
  3937. try {
  3938. const parsed = JSON.parse(data);
  3939. responseObj = parsed;
  3940. if (parsed.choices && parsed.choices[0]) {
  3941. if (parsed.choices[0].delta && parsed.choices[0].delta.content !== undefined) {
  3942. const delta = parsed.choices[0].delta.content || "";
  3943. content += delta;
  3944. }
  3945. if (parsed.choices[0].delta && parsed.choices[0].delta.reasoning !== undefined) {
  3946. const reasoningDelta = parsed.choices[0].delta.reasoning || "";
  3947. reasoning += reasoningDelta;
  3948. }
  3949. onChunk({
  3950. chunk: parsed.choices[0].delta?.content || "",
  3951. reasoningChunk: parsed.choices[0].delta?.reasoning || "",
  3952. content: content,
  3953. reasoning: reasoning,
  3954. data: parsed
  3955. });
  3956. }
  3957. } catch (e) {
  3958. }
  3959. }
  3960. }
  3961. }
  3962. if (!streamComplete) {
  3963. streamComplete = true;
  3964. if (streamTimeout) clearTimeout(streamTimeout);
  3965. if (tweetId && window.activeStreamingRequests) {
  3966. delete window.activeStreamingRequests[tweetId];
  3967. }
  3968. onComplete({
  3969. content: content,
  3970. reasoning: reasoning,
  3971. fullResponse: fullResponse,
  3972. data: responseObj
  3973. });
  3974. }
  3975. } catch (error) {
  3976. if (streamTimeout) clearTimeout(streamTimeout);
  3977. if (!streamComplete) {
  3978. streamComplete = true;
  3979. if (tweetId && window.activeStreamingRequests) {
  3980. delete window.activeStreamingRequests[tweetId];
  3981. }
  3982. onError({
  3983. error: true,
  3984. message: `Stream processing error: ${error.toString()}`,
  3985. data: null
  3986. });
  3987. }
  3988. }
  3989. };
  3990. processStream().catch(error => {
  3991. if (streamTimeout) clearTimeout(streamTimeout);
  3992. if (!streamComplete) {
  3993. streamComplete = true;
  3994. if (tweetId && window.activeStreamingRequests) {
  3995. delete window.activeStreamingRequests[tweetId];
  3996. }
  3997. onError({
  3998. error: true,
  3999. message: `Unhandled stream error: ${error.toString()}`,
  4000. data: null
  4001. });
  4002. }
  4003. });
  4004. },
  4005. onerror: function(error) {
  4006. if (tweetId && window.activeStreamingRequests) {
  4007. delete window.activeStreamingRequests[tweetId];
  4008. }
  4009. onError({
  4010. error: true,
  4011. message: `Request error: ${error.toString()}`,
  4012. data: null
  4013. });
  4014. },
  4015. ontimeout: function() {
  4016. if (tweetId && window.activeStreamingRequests) {
  4017. delete window.activeStreamingRequests[tweetId];
  4018. }
  4019. onError({
  4020. error: true,
  4021. message: `Request timed out after ${timeout}ms`,
  4022. data: null
  4023. });
  4024. }
  4025. });
  4026. const streamingRequestObj = {
  4027. abort: function() {
  4028. streamComplete = true;
  4029. pendingRequests--;
  4030. try {
  4031. reqObj.abort();
  4032. } catch (e) {
  4033. }
  4034. if (tweetId && window.activeStreamingRequests) {
  4035. delete window.activeStreamingRequests[tweetId];
  4036. }
  4037. if (tweetId && tweetCache.has(tweetId)) {
  4038. const entry = tweetCache.get(tweetId);
  4039. if (entry.streaming && (entry.score === undefined || entry.score === null)) {
  4040. tweetCache.delete(tweetId);
  4041. }
  4042. }
  4043. }
  4044. };
  4045. if (tweetId && window.activeStreamingRequests) {
  4046. window.activeStreamingRequests[tweetId] = streamingRequestObj;
  4047. }
  4048. return streamingRequestObj;
  4049. }
  4050. function fetchAvailableModels() {
  4051. const apiKey = browserGet('openrouter-api-key', '');
  4052. if (!apiKey) {
  4053. showStatus('Please enter your OpenRouter API key');
  4054. return;
  4055. }
  4056. showStatus('Fetching available models...');
  4057. const sortOrder = browserGet('modelSortOrder', 'throughput-high-to-low');
  4058. GM_xmlhttpRequest({
  4059. method: "GET",
  4060. url: `https://openrouter.ai/api/frontend/models/find?order=${sortOrder}`,
  4061. headers: {
  4062. "Authorization": `Bearer ${apiKey}`,
  4063. "HTTP-Referer": "https://greasyfork.org/en/scripts/532182-twitter-x-ai-tweet-filter",
  4064. "X-Title": "Tweet Rating Tool"
  4065. },
  4066. onload: function (response) {
  4067. try {
  4068. const data = JSON.parse(response.responseText);
  4069. if (data.data && data.data.models) {
  4070. let filteredModels = data.data.models.filter(model => model.endpoint && model.endpoint !== null);
  4071. filteredModels.forEach(model => {
  4072. const endpointPricing = model.endpoint?.pricing;
  4073. const isFree = !endpointPricing || (
  4074. (endpointPricing.completion == null || parseFloat(endpointPricing.completion) === 0) &&
  4075. (endpointPricing.prompt == null || parseFloat(endpointPricing.prompt) === 0)
  4076. );
  4077. if (isFree && model.slug && !model.slug.endsWith(':free')) {
  4078. model.slug += ':free';
  4079. }
  4080. });
  4081. if (sortOrder === 'latency-low-to-high'|| sortOrder === 'pricing-low-to-high') {
  4082. filteredModels.reverse();
  4083. }
  4084. availableModels = filteredModels || [];
  4085. listedModels = [...availableModels];
  4086. refreshModelsUI();
  4087. showStatus('Models updated!');
  4088. }
  4089. } catch (error) {
  4090. showStatus('Error parsing models list');
  4091. }
  4092. },
  4093. onerror: function (error) {
  4094. showStatus('Error fetching models!');
  4095. }
  4096. });
  4097. }
  4098. async function getImageDescription(urls, apiKey, tweetId, userHandle) {
  4099. if (!urls?.length || !enableImageDescriptions) {
  4100. return !enableImageDescriptions ? '[Image descriptions disabled]' : '';
  4101. }
  4102. let descriptions = [];
  4103. for (const url of urls) {
  4104. const request = {
  4105. model: selectedImageModel,
  4106. messages: [{
  4107. role: "user",
  4108. content: [
  4109. {
  4110. type: "text",
  4111. 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."
  4112. },
  4113. {
  4114. type: "image_url",
  4115. image_url: { url }
  4116. }
  4117. ]
  4118. }],
  4119. temperature: imageModelTemperature,
  4120. top_p: imageModelTopP,
  4121. max_tokens: maxTokens,
  4122. };
  4123. if (selectedImageModel.includes('gemini')) {
  4124. request.config = {
  4125. safetySettings: safetySettings,
  4126. }
  4127. }
  4128. if (providerSort) {
  4129. request.provider = {
  4130. sort: providerSort,
  4131. allow_fallbacks: true
  4132. };
  4133. }
  4134. const result = await getCompletion(request, apiKey);
  4135. if (!result.error && result.data?.choices?.[0]?.message?.content) {
  4136. descriptions.push(result.data.choices[0].message.content);
  4137. } else {
  4138. descriptions.push('[Error getting image description]');
  4139. }
  4140. }
  4141. return descriptions.map((desc, i) => `[IMAGE ${i + 1}]: ${desc}`).join('\n');
  4142. }
  4143. async function getGenerationMetadata(generationId, apiKey, timeout = 10000) {
  4144. return new Promise((resolve) => {
  4145. GM_xmlhttpRequest({
  4146. method: "GET",
  4147. url: `https://openrouter.ai/api/v1/generation?id=${generationId}`,
  4148. headers: {
  4149. "Authorization": `Bearer ${apiKey}`,
  4150. "HTTP-Referer": "https://greasyfork.org/en/scripts/532459-tweetfilter-ai", // Use your script's URL
  4151. "X-Title": "TweetFilter-AI" // Replace with your script's name
  4152. },
  4153. timeout: timeout,
  4154. onload: function(response) {
  4155. if (response.status >= 200 && response.status < 300) {
  4156. try {
  4157. const data = JSON.parse(response.responseText);
  4158. resolve({
  4159. error: false,
  4160. message: "Metadata fetched successfully",
  4161. data: data // The structure is { data: { ...metadata... } }
  4162. });
  4163. } catch (error) {
  4164. resolve({
  4165. error: true,
  4166. message: `Failed to parse metadata response: ${error.message}`,
  4167. data: null
  4168. });
  4169. }
  4170. } else if (response.status === 404) {
  4171. resolve({
  4172. error: true,
  4173. status: 404, // Indicate not found specifically for retry logic
  4174. message: `Generation metadata not found (404): ${response.responseText}`,
  4175. data: null
  4176. });
  4177. } else {
  4178. resolve({
  4179. error: true,
  4180. status: response.status,
  4181. message: `Metadata request failed with status ${response.status}: ${response.responseText}`,
  4182. data: null
  4183. });
  4184. }
  4185. },
  4186. onerror: function(error) {
  4187. resolve({
  4188. error: true,
  4189. message: `Metadata request error: ${error.toString()}`,
  4190. data: null
  4191. });
  4192. },
  4193. ontimeout: function() {
  4194. resolve({
  4195. error: true,
  4196. message: `Metadata request timed out after ${timeout}ms`,
  4197. data: null
  4198. });
  4199. }
  4200. });
  4201. });
  4202. }
  4203. // ----- api/api.js -----
  4204. const safetySettings = [
  4205. {
  4206. category: "HARM_CATEGORY_HARASSMENT",
  4207. threshold: "BLOCK_NONE",
  4208. },
  4209. {
  4210. category: "HARM_CATEGORY_HATE_SPEECH",
  4211. threshold: "BLOCK_NONE",
  4212. },
  4213. {
  4214. category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
  4215. threshold: "BLOCK_NONE",
  4216. },
  4217. {
  4218. category: "HARM_CATEGORY_DANGEROUS_CONTENT",
  4219. threshold: "BLOCK_NONE",
  4220. },
  4221. {
  4222. category: "HARM_CATEGORY_CIVIC_INTEGRITY",
  4223. threshold: "BLOCK_NONE",
  4224. },
  4225. ];
  4226. function extractFollowUpQuestions(content) {
  4227. if (!content) return [];
  4228. const questions = [];
  4229. const q1Marker = "Q_1.";
  4230. const q2Marker = "Q_2.";
  4231. const q3Marker = "Q_3.";
  4232. const q1Start = content.indexOf(q1Marker);
  4233. const q2Start = content.indexOf(q2Marker);
  4234. const q3Start = content.indexOf(q3Marker);
  4235. if (q1Start !== -1 && q2Start > q1Start && q3Start > q2Start) {
  4236. const q1Text = content.substring(q1Start + q1Marker.length, q2Start).trim();
  4237. questions.push(q1Text);
  4238. const q2Text = content.substring(q2Start + q2Marker.length, q3Start).trim();
  4239. questions.push(q2Text);
  4240. let q3Text = content.substring(q3Start + q3Marker.length).trim();
  4241. const endMarker = "</FOLLOW_UP_QUESTIONS>";
  4242. if (q3Text.endsWith(endMarker)) {
  4243. q3Text = q3Text.substring(0, q3Text.length - endMarker.length).trim();
  4244. }
  4245. questions.push(q3Text);
  4246. if (questions.every(q => q.length > 0)) {
  4247. return questions;
  4248. }
  4249. }
  4250. return [];
  4251. }
  4252. async function rateTweetWithOpenRouter(tweetText, tweetId, apiKey, mediaUrls, maxRetries = 3, tweetArticle = null, authorHandle="") {
  4253. const cleanupRequest = () => {
  4254. pendingRequests = Math.max(0, pendingRequests - 1);
  4255. showStatus(`Rating tweet... (${pendingRequests} pending)`);
  4256. };
  4257. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle);
  4258. if (!indicatorInstance) {
  4259. return {
  4260. score: 5, // Default error score
  4261. content: "Failed to initialize UI components for rating.",
  4262. reasoning: "",
  4263. questions: [],
  4264. lastAnswer: "",
  4265. error: true,
  4266. cached: false,
  4267. data: null,
  4268. qaConversationHistory: [] // Empty history
  4269. };
  4270. }
  4271. if (adAuthorCache.has(authorHandle)) {
  4272. indicatorInstance.updateInitialReviewAndBuildHistory({
  4273. fullContext: tweetText, // or a specific ad message
  4274. mediaUrls: [],
  4275. apiResponseContent: "<ANALYSIS>This tweet is from an ad author.</ANALYSIS><SCORE>SCORE_0</SCORE><FOLLOW_UP_QUESTIONS>Q_1. N/A\\nQ_2. N/A\\nQ_3. N/A</FOLLOW_UP_QUESTIONS>",
  4276. reviewSystemPrompt: REVIEW_SYSTEM_PROMPT, // Globally available from config.js
  4277. followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT // Globally available from config.js
  4278. });
  4279. return {
  4280. score: 0,
  4281. content: indicatorInstance.description,
  4282. reasoning: "",
  4283. error: false,
  4284. cached: false,
  4285. questions: indicatorInstance.questions,
  4286. qaConversationHistory: indicatorInstance.qaConversationHistory
  4287. };
  4288. }
  4289. const currentInstructions = instructionsManager.getCurrentInstructions();
  4290. const requestBody = {
  4291. model: selectedModel,
  4292. messages: [
  4293. {
  4294. role: "system",
  4295. content: [{ type: "text", text: REVIEW_SYSTEM_PROMPT}]
  4296. },
  4297. {
  4298. role: "user",
  4299. content: [
  4300. {
  4301. type: "text",
  4302. text: `<TARGET_TWEET_ID>[${tweetId}]</TARGET_TWEET_ID>
  4303. <USER_INSTRUCTIONS>[${currentInstructions}]</USER_INSTRUCTIONS>
  4304. <TWEET>[${tweetText}]</TWEET>
  4305. Follow this expected response format exactly, or you break the UI:
  4306. <EXPECTED_RESPONSE_FORMAT>
  4307. <ANALYSIS>
  4308. (Your analysis according to the user instructions.)
  4309. </ANALYSIS>
  4310. <SCORE>
  4311. SCORE_X (Where X is a number between 0 and 10)
  4312. </SCORE>
  4313. <FOLLOW_UP_QUESTIONS>
  4314. Q_1.
  4315. Q_2.
  4316. Q_3.
  4317. </FOLLOW_UP_QUESTIONS>
  4318. </EXPECTED_RESPONSE_FORMAT>
  4319. `
  4320. }
  4321. ]
  4322. }
  4323. ],
  4324. temperature: modelTemperature,
  4325. top_p: modelTopP,
  4326. max_tokens: maxTokens
  4327. };
  4328. if (selectedModel.includes('gemini')) {
  4329. requestBody.config = { safetySettings: safetySettings };
  4330. }
  4331. if (mediaUrls?.length > 0 && modelSupportsImages(selectedModel)) {
  4332. mediaUrls.forEach(url => {
  4333. requestBody.messages[1].content.push({
  4334. type: "image_url",
  4335. image_url: { "url": url }
  4336. });
  4337. });
  4338. }
  4339. if (providerSort) {
  4340. requestBody.provider = { sort: providerSort, allow_fallbacks: true };
  4341. }
  4342. const useStreaming = browserGet('enableStreaming', false);
  4343. tweetCache.set(tweetId, {
  4344. streaming: true,
  4345. timestamp: Date.now(),
  4346. tweetContent: tweetText, // Store original tweet text for context
  4347. mediaUrls: mediaUrls // Store original media URLs
  4348. });
  4349. let attempt = 0;
  4350. while (attempt < maxRetries) {
  4351. attempt++;
  4352. const now = Date.now();
  4353. const timeElapsed = now - lastAPICallTime;
  4354. if (timeElapsed < API_CALL_DELAY_MS) {
  4355. await new Promise(resolve => setTimeout(resolve, API_CALL_DELAY_MS - timeElapsed));
  4356. }
  4357. lastAPICallTime = now;
  4358. pendingRequests++;
  4359. showStatus(`Rating tweet... (${pendingRequests} pending)`);
  4360. try {
  4361. let result;
  4362. if (useStreaming) {
  4363. result = await rateTweetStreaming(requestBody, apiKey, tweetId, tweetText, tweetArticle);
  4364. } else {
  4365. result = await rateTweet(requestBody, apiKey);
  4366. }
  4367. cleanupRequest();
  4368. if (!result.error && result.content) {
  4369. indicatorInstance.updateInitialReviewAndBuildHistory({
  4370. fullContext: tweetText, // The full text of the tweet that was rated
  4371. mediaUrls: mediaUrls, // The media URLs associated with that tweet
  4372. apiResponseContent: result.content,
  4373. reviewSystemPrompt: REVIEW_SYSTEM_PROMPT,
  4374. followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT
  4375. });
  4376. const finalScore = indicatorInstance.score;
  4377. const finalQuestions = indicatorInstance.questions;
  4378. const finalDescription = indicatorInstance.description;
  4379. const finalQaHistory = indicatorInstance.qaConversationHistory;
  4380. tweetCache.set(tweetId, {
  4381. score: finalScore,
  4382. description: finalDescription, // Analysis
  4383. reasoning: result.reasoning || "", // If rateTweet/Streaming provide it separately
  4384. questions: finalQuestions,
  4385. lastAnswer: "",
  4386. tweetContent: tweetText,
  4387. mediaUrls: mediaUrls,
  4388. streaming: false,
  4389. timestamp: Date.now(),
  4390. metadata: result.data?.id ? { generationId: result.data.id } : null,
  4391. qaConversationHistory: finalQaHistory // Store the history
  4392. });
  4393. return {
  4394. score: finalScore,
  4395. content: result.content, // Keep raw content for direct use if needed
  4396. reasoning: result.reasoning || "",
  4397. questions: finalQuestions,
  4398. error: false,
  4399. cached: false,
  4400. data: result.data,
  4401. qaConversationHistory: finalQaHistory
  4402. };
  4403. }
  4404. if (attempt < maxRetries && (result.error || !result.content)) {
  4405. const backoffDelay = Math.pow(attempt, 2) * 1000;
  4406. await new Promise(resolve => setTimeout(resolve, backoffDelay));
  4407. } else if (result.error || !result.content) {
  4408. throw new Error(result.content || "Failed to get valid rating content after multiple attempts");
  4409. }
  4410. } catch (error) {
  4411. cleanupRequest();
  4412. if (attempt < maxRetries) {
  4413. const backoffDelay = Math.pow(attempt, 2) * 1000;
  4414. await new Promise(resolve => setTimeout(resolve, backoffDelay));
  4415. } else {
  4416. const errorContent = `Failed to get valid rating after multiple attempts: ${error.message}`;
  4417. indicatorInstance.updateInitialReviewAndBuildHistory({
  4418. fullContext: tweetText,
  4419. mediaUrls: mediaUrls,
  4420. apiResponseContent: `<ANALYSIS>${errorContent}</ANALYSIS><SCORE>SCORE_5</SCORE><FOLLOW_UP_QUESTIONS>Q_1. N/A\\nQ_2. N/A\\nQ_3. N/A</FOLLOW_UP_QUESTIONS>`,
  4421. reviewSystemPrompt: REVIEW_SYSTEM_PROMPT,
  4422. followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT
  4423. });
  4424. tweetCache.set(tweetId, {
  4425. score: 5,
  4426. description: errorContent,
  4427. reasoning: "",
  4428. questions: [],
  4429. lastAnswer: "",
  4430. error: true,
  4431. tweetContent: tweetText,
  4432. mediaUrls: mediaUrls,
  4433. streaming: false,
  4434. timestamp: Date.now(),
  4435. qaConversationHistory: indicatorInstance.qaConversationHistory
  4436. });
  4437. return {
  4438. score: 5,
  4439. content: errorContent,
  4440. reasoning: "",
  4441. questions: [],
  4442. lastAnswer: "",
  4443. error: true,
  4444. data: null,
  4445. qaConversationHistory: indicatorInstance.qaConversationHistory
  4446. };
  4447. }
  4448. }
  4449. }
  4450. cleanupRequest();
  4451. const fallbackError = "Unexpected failure in rating process.";
  4452. indicatorInstance.updateInitialReviewAndBuildHistory({
  4453. fullContext: tweetText,
  4454. mediaUrls: mediaUrls,
  4455. apiResponseContent: `<ANALYSIS>${fallbackError}</ANALYSIS><SCORE>SCORE_5</SCORE><FOLLOW_UP_QUESTIONS>Q_1. N/A\\nQ_2. N/A\\nQ_3. N/A</FOLLOW_UP_QUESTIONS>`,
  4456. reviewSystemPrompt: REVIEW_SYSTEM_PROMPT,
  4457. followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT
  4458. });
  4459. return {
  4460. score: 5,
  4461. content: fallbackError,
  4462. reasoning: "",
  4463. questions: [],
  4464. lastAnswer: "",
  4465. error: true,
  4466. data: null,
  4467. qaConversationHistory: indicatorInstance.qaConversationHistory
  4468. };
  4469. }
  4470. async function getCustomInstructionsDescription(instructions) {
  4471. const INSTRUCTION_SUMMARY_MODEL = "google/gemini-2.5-flash-preview";
  4472. const request={
  4473. model: INSTRUCTION_SUMMARY_MODEL,
  4474. messages: [{
  4475. role: "system",
  4476. content: [{
  4477. type: "text",
  4478. text: `
  4479. Please come up with a 5-word summary of the following instructions.
  4480. `
  4481. }]
  4482. },
  4483. {
  4484. role: "user",
  4485. content: [{
  4486. type: "text",
  4487. text: `Please come up with a 5-word summary of the following instructions:
  4488. ${instructions}
  4489. `
  4490. }]
  4491. }]
  4492. }
  4493. let key = browserGet('openrouter-api-key');
  4494. const result = await getCompletion(request,key);
  4495. if (!result.error && result.data?.choices?.[0]?.message) {
  4496. const content = result.data.choices[0].message.content || "";
  4497. return {
  4498. content,
  4499. error: false,
  4500. };
  4501. }
  4502. return {
  4503. error: true,
  4504. content: result.error || "Unknown error"
  4505. };
  4506. }
  4507. async function rateTweet(request, apiKey) {
  4508. const tweetId = request.tweetId;
  4509. const existingScore = tweetCache.get(tweetId)?.score;
  4510. const result = await getCompletion(request, apiKey);
  4511. if (!result.error && result.data?.choices?.[0]?.message) {
  4512. const content = result.data.choices[0].message.content || "";
  4513. const reasoning = result.data.choices[0].message.reasoning || "";
  4514. const scoreMatches = content.match(/SCORE_(\d+)/g);
  4515. const score = existingScore || (scoreMatches && scoreMatches.length > 0
  4516. ? parseInt(scoreMatches[scoreMatches.length - 1].match(/SCORE_(\d+)/)[1], 10)
  4517. : null);
  4518. tweetCache.set(tweetId, {
  4519. score: score,
  4520. description: content,
  4521. tweetContent: request.tweetText,
  4522. streaming: false
  4523. });
  4524. return {
  4525. content,
  4526. reasoning
  4527. };
  4528. }
  4529. return {
  4530. error: true,
  4531. content: result.error || "Unknown error",
  4532. reasoning: "",
  4533. data: null
  4534. };
  4535. }
  4536. async function rateTweetStreaming(request, apiKey, tweetId, tweetText, tweetArticle) {
  4537. if (window.activeStreamingRequests && window.activeStreamingRequests[tweetId]) {
  4538. window.activeStreamingRequests[tweetId].abort();
  4539. delete window.activeStreamingRequests[tweetId];
  4540. }
  4541. const existingCache = tweetCache.get(tweetId);
  4542. if (!existingCache || existingCache.score === undefined || existingCache.score === null) {
  4543. tweetCache.set(tweetId, {
  4544. streaming: true,
  4545. timestamp: Date.now(),
  4546. tweetContent: tweetText,
  4547. description: "",
  4548. reasoning: "",
  4549. questions: [],
  4550. lastAnswer: "",
  4551. score: null
  4552. });
  4553. }
  4554. return new Promise((resolve, reject) => {
  4555. const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle);
  4556. if (!indicatorInstance) {
  4557. if (tweetCache.has(tweetId)) {
  4558. tweetCache.get(tweetId).streaming = false;
  4559. tweetCache.get(tweetId).error = "Indicator initialization failed";
  4560. }
  4561. return reject(new Error(`ScoreIndicator instance could not be initialized for tweet ${tweetId}`));
  4562. }
  4563. let aggregatedContent = existingCache?.description || "";
  4564. let aggregatedReasoning = existingCache?.reasoning || "";
  4565. let aggregatedQuestions = existingCache?.questions || [];
  4566. let finalData = null;
  4567. let score = existingCache?.score || null;
  4568. getCompletionStreaming(
  4569. request,
  4570. apiKey,
  4571. (chunkData) => {
  4572. aggregatedContent = chunkData.content || aggregatedContent;
  4573. aggregatedReasoning = chunkData.reasoning || aggregatedReasoning;
  4574. const scoreMatches = aggregatedContent.match(/SCORE_(\d+)/g);
  4575. if (scoreMatches && scoreMatches.length > 0) {
  4576. const lastScore = scoreMatches[scoreMatches.length - 1];
  4577. score = parseInt(lastScore.match(/SCORE_(\d+)/)[1], 10);
  4578. }
  4579. indicatorInstance.update({
  4580. status: 'streaming',
  4581. score: score,
  4582. description: aggregatedContent || "Rating in progress...",
  4583. reasoning: aggregatedReasoning,
  4584. questions: [],
  4585. lastAnswer: ""
  4586. });
  4587. if (tweetCache.has(tweetId)) {
  4588. const entry = tweetCache.get(tweetId);
  4589. entry.description = aggregatedContent;
  4590. entry.reasoning = aggregatedReasoning;
  4591. entry.score = score;
  4592. entry.streaming = true;
  4593. }
  4594. },
  4595. (finalResult) => {
  4596. aggregatedContent = finalResult.content || aggregatedContent;
  4597. aggregatedReasoning = finalResult.reasoning || aggregatedReasoning;
  4598. finalData = finalResult.data;
  4599. const scoreMatches = aggregatedContent.match(/SCORE_(\d+)/g);
  4600. if (scoreMatches && scoreMatches.length > 0) {
  4601. const lastScore = scoreMatches[scoreMatches.length - 1];
  4602. score = parseInt(lastScore.match(/SCORE_(\d+)/)[1], 10);
  4603. }
  4604. let finalStatus = 'rated';
  4605. if (score === null || score === undefined) {
  4606. finalStatus = 'error';
  4607. score = 5;
  4608. aggregatedContent += "\n[No score detected - Error]";
  4609. }
  4610. const finalCacheData = {
  4611. tweetContent: tweetText,
  4612. score: score,
  4613. description: aggregatedContent,
  4614. reasoning: aggregatedReasoning,
  4615. streaming: false,
  4616. timestamp: Date.now(),
  4617. error: finalStatus === 'error' ? "No score detected" : undefined,
  4618. metadata: finalData?.id ? { generationId: finalData.id } : null
  4619. };
  4620. tweetCache.set(tweetId, finalCacheData);
  4621. indicatorInstance.update({
  4622. status: finalStatus,
  4623. score: score,
  4624. description: aggregatedContent,
  4625. reasoning: aggregatedReasoning,
  4626. questions: extractFollowUpQuestions(aggregatedContent),
  4627. lastAnswer: "",
  4628. metadata: finalData?.id ? { generationId: finalData.id } : null
  4629. });
  4630. if (tweetArticle) {
  4631. filterSingleTweet(tweetArticle);
  4632. }
  4633. const generationId = finalData?.id;
  4634. if (generationId && apiKey) {
  4635. fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance);
  4636. }
  4637. resolve({
  4638. score: score,
  4639. content: aggregatedContent,
  4640. reasoning: aggregatedReasoning,
  4641. error: finalStatus === 'error',
  4642. cached: false,
  4643. data: finalData
  4644. });
  4645. },
  4646. (errorData) => {
  4647. indicatorInstance.update({
  4648. status: 'error',
  4649. score: 5,
  4650. description: `Stream Error: ${errorData.message}`,
  4651. reasoning: '',
  4652. questions: [],
  4653. lastAnswer: ''
  4654. });
  4655. if (tweetCache.has(tweetId)) {
  4656. const entry = tweetCache.get(tweetId);
  4657. entry.streaming = false;
  4658. entry.error = errorData.message;
  4659. entry.score = 5;
  4660. entry.description = `Stream Error: ${errorData.message}`;
  4661. }
  4662. reject(new Error(errorData.message));
  4663. },
  4664. 30000,
  4665. tweetId // Pass the tweet ID to associate with this request
  4666. );
  4667. });
  4668. }
  4669. async function fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance, attempt = 0, delays = [1000, 500, 2000, 4000, 8000]) {
  4670. if (attempt >= delays.length) {
  4671. return;
  4672. }
  4673. const delay = delays[attempt];
  4674. await new Promise(resolve => setTimeout(resolve, delay));
  4675. try {
  4676. const metadataResult = await getGenerationMetadata(generationId, apiKey);
  4677. if (!metadataResult.error && metadataResult.data?.data) {
  4678. const meta = metadataResult.data.data;
  4679. const extractedMetadata = {
  4680. model: meta.model || 'N/A',
  4681. promptTokens: meta.tokens_prompt || 0,
  4682. completionTokens: meta.tokens_completion || 0, // Use this for total completion output
  4683. reasoningTokens: meta.native_tokens_reasoning || 0, // Specific reasoning tokens if available
  4684. latency: meta.latency !== undefined ? (meta.latency / 1000).toFixed(2) + 's' : 'N/A', // Convert ms to s
  4685. mediaInputs: meta.num_media_prompt || 0,
  4686. price: meta.total_cost !== undefined ? `$${meta.total_cost.toFixed(6)}` : 'N/A' // Add total cost
  4687. };
  4688. const currentCache = tweetCache.get(tweetId);
  4689. if (currentCache) {
  4690. currentCache.metadata = extractedMetadata;
  4691. tweetCache.set(tweetId, currentCache);
  4692. indicatorInstance.update({ metadata: extractedMetadata });
  4693. } else {
  4694. }
  4695. return;
  4696. } else if (metadataResult.status === 404) {
  4697. fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance, attempt + 1, delays);
  4698. } else {
  4699. fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance, attempt + 1, delays);
  4700. }
  4701. } catch (error) {
  4702. fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance, attempt + 1, delays);
  4703. }
  4704. }
  4705. async function answerFollowUpQuestion(tweetId, qaHistoryForApiCall, apiKey, tweetArticle, indicatorInstance) {
  4706. const questionText = qaHistoryForApiCall.find(m => m.role === 'user' && m === qaHistoryForApiCall[qaHistoryForApiCall.length - 1])?.content.find(c => c.type === 'text')?.text || "User's question";
  4707. const useStreaming = browserGet('enableStreaming', false);
  4708. const request = {
  4709. model: selectedModel,//was using :online but the search results are irrelevant
  4710. messages: qaHistoryForApiCall, // The entire history IS the messages array
  4711. temperature: modelTemperature,
  4712. top_p: modelTopP,
  4713. max_tokens: maxTokens,
  4714. stream: useStreaming
  4715. };
  4716. if (selectedModel.includes('gemini')) {
  4717. request.config = { safetySettings: safetySettings };
  4718. }
  4719. if (providerSort) {
  4720. request.provider = { sort: providerSort, allow_fallbacks: true };
  4721. }
  4722. try {
  4723. let finalAnswerContent = "*Processing...*";
  4724. let finalQaHistory = [...qaHistoryForApiCall];
  4725. if (useStreaming) {
  4726. await new Promise((resolve, reject) => {
  4727. let aggregatedContent = "";
  4728. getCompletionStreaming(
  4729. request, apiKey,
  4730. (chunkData) => {
  4731. aggregatedContent = chunkData.content || aggregatedContent;
  4732. indicatorInstance._renderStreamingAnswer(aggregatedContent, "");
  4733. },
  4734. (result) => {
  4735. finalAnswerContent = result.content || aggregatedContent;
  4736. const assistantMessage = { role: "assistant", content: [{ type: "text", text: finalAnswerContent }] };
  4737. finalQaHistory.push(assistantMessage);
  4738. indicatorInstance.updateAfterFollowUp({
  4739. assistantResponseContent: finalAnswerContent,
  4740. updatedQaHistory: finalQaHistory
  4741. });
  4742. const currentCache = tweetCache.get(tweetId) || {};
  4743. currentCache.qaConversationHistory = finalQaHistory;
  4744. const parsedAnswer = finalAnswerContent.match(/<ANSWER>([\s\S]*?)<\/ANSWER>/);
  4745. currentCache.lastAnswer = parsedAnswer ? parsedAnswer[1].trim() : finalAnswerContent;
  4746. currentCache.questions = extractFollowUpQuestions(finalAnswerContent);
  4747. currentCache.timestamp = Date.now();
  4748. tweetCache.set(tweetId, currentCache);
  4749. resolve();
  4750. },
  4751. (error) => {
  4752. const errorMessage = `Error generating answer: ${error.message}`;
  4753. indicatorInstance._updateConversationHistory(questionText, errorMessage);
  4754. indicatorInstance.questions = tweetCache.get(tweetId)?.questions || [];
  4755. indicatorInstance._updateTooltipUI();
  4756. const currentCache = tweetCache.get(tweetId) || {};
  4757. currentCache.lastAnswer = errorMessage;
  4758. currentCache.timestamp = Date.now();
  4759. tweetCache.set(tweetId, currentCache);
  4760. reject(new Error(error.message));
  4761. },
  4762. 60000,
  4763. `followup-${tweetId}`
  4764. );
  4765. });
  4766. } else {
  4767. const result = await getCompletion(request, apiKey, 60000);
  4768. if (result.error || !result.data?.choices?.[0]?.message?.content) {
  4769. throw new Error(result.message || "Failed to get follow-up answer.");
  4770. }
  4771. finalAnswerContent = result.data.choices[0].message.content;
  4772. const assistantMessage = { role: "assistant", content: [{ type: "text", text: finalAnswerContent }] };
  4773. finalQaHistory.push(assistantMessage);
  4774. indicatorInstance.updateAfterFollowUp({
  4775. assistantResponseContent: finalAnswerContent,
  4776. updatedQaHistory: finalQaHistory
  4777. });
  4778. const currentCache = tweetCache.get(tweetId) || {};
  4779. currentCache.qaConversationHistory = finalQaHistory;
  4780. const parsedAnswer = finalAnswerContent.match(/<ANSWER>([\s\S]*?)<\/ANSWER>/);
  4781. currentCache.lastAnswer = parsedAnswer ? parsedAnswer[1].trim() : finalAnswerContent;
  4782. currentCache.questions = extractFollowUpQuestions(finalAnswerContent);
  4783. currentCache.timestamp = Date.now();
  4784. tweetCache.set(tweetId, currentCache);
  4785. }
  4786. } catch (error) {
  4787. const errorMessage = `Error answering question: ${error.message}`;
  4788. indicatorInstance._updateConversationHistory(questionText, errorMessage);
  4789. indicatorInstance.questions = tweetCache.get(tweetId)?.questions || [];
  4790. indicatorInstance._updateTooltipUI();
  4791. const currentCache = tweetCache.get(tweetId) || {};
  4792. currentCache.lastAnswer = errorMessage;
  4793. currentCache.timestamp = Date.now();
  4794. tweetCache.set(tweetId, currentCache);
  4795. }
  4796. }
  4797. // ----- twitter-desloppifier.js -----
  4798. const VERSION = '1.5';
  4799. (function () {
  4800. 'use strict';
  4801. let menuhtml = GM_getResourceText("MENU_HTML");
  4802. browserSet('menuHTML', menuhtml);
  4803. let firstRun = browserGet('firstRun', true);
  4804. function initializeObserver() {
  4805. const target = document.querySelector('main') || document.querySelector('div[data-testid="primaryColumn"]');
  4806. if (target) {
  4807. observedTargetNode = target;
  4808. initialiseUI();
  4809. if (firstRun) {
  4810. resetSettings(true);
  4811. browserSet('firstRun', false);
  4812. }
  4813. let apiKey = browserGet('openrouter-api-key', '');
  4814. if(!apiKey){
  4815. alert("No API Key found. Please enter your API Key in Settings > General.")
  4816. }
  4817. if (apiKey) {
  4818. browserSet('openrouter-api-key', apiKey);
  4819. showStatus(`Loaded ${tweetCache.size} cached ratings. Starting to rate visible tweets...`);
  4820. fetchAvailableModels();
  4821. }
  4822. observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR).forEach(scheduleTweetProcessing);
  4823. applyFilteringToAll();
  4824. const observer = new MutationObserver(handleMutations);
  4825. observer.observe(observedTargetNode, { childList: true, subtree: true });
  4826. window.addEventListener('beforeunload', () => {
  4827. observer.disconnect();
  4828. const sliderUI = document.getElementById('tweet-filter-container');
  4829. if (sliderUI) sliderUI.remove();
  4830. const settingsUI = document.getElementById('settings-container');
  4831. if (settingsUI) settingsUI.remove();
  4832. const statusIndicator = document.getElementById('status-indicator');
  4833. if (statusIndicator) statusIndicator.remove();
  4834. ScoreIndicatorRegistry.destroyAll();
  4835. });
  4836. } else {
  4837. setTimeout(initializeObserver, 1000);
  4838. }
  4839. }
  4840. initializeObserver();
  4841. })();
  4842. })();