您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
A highly customizable AI rates tweets 1-10 and removes all the slop, saving your braincells!
// ==UserScript== // @name TweetFilter AI // @namespace http://tampermonkey.net/ // @version Version 1.5.4 // @description A highly customizable AI rates tweets 1-10 and removes all the slop, saving your braincells! // @author Obsxrver(3than) // @match *://twitter.com/* // @match *://x.com/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_getResourceText // @connect openrouter.ai // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; console.log("X/Twitter Tweet De-Sloppification Activated (Combined Version)"); // Embedded Menu.html const MENU = `<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 & 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 class="toggle-row"><div class="toggle-label" title="Enable web search capabilities for the model. Appends ':online' to the model slug.">Enable Web Search</div><label class="toggle-switch"><input type="checkbox" data-setting="enableWebSearch"><span class="toggle-slider"></span></label></div><div class="toggle-row"><div class="toggle-label" title="Automatically send tweets to API for rating. When disabled, tweets will show a 'Rate' button instead.">Auto-Rate Tweets</div><label class="toggle-switch"><input type="checkbox" data-setting="enableAutoRating"><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>`; // Embedded style.css 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: 8px;}.score-description::-webkit-scrollbar-track {background: rgba(22, 24, 28, 0.1);border-radius: 4px;}.score-description::-webkit-scrollbar-thumb {background: rgba(255, 255, 255, 0.3);border-radius: 4px;border: 1px solid rgba(22, 24, 28, 0.2);}.score-description::-webkit-scrollbar-thumb:hover {background: rgba(255, 255, 255, 0.5);}.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;}.manual-rating {background-color: rgba(33, 150, 243, 0.7) !important;color: white !important;border: 2px dashed rgba(33, 150, 243, 0.8) !important;}/* New style for blacklisted author indicator */.blacklisted-author-indicator {background-color: purple !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: flex;flex-direction: column;background-color: rgba(22, 24, 28, 0.95);color: #e7e9ea;padding: 0;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: 600px !important;max-width: 85vw !important;max-height: 70vh;border: 1px solid rgba(255, 255, 255, 0.1);word-wrap: break-word;box-sizing: border-box !important;}.tooltip-scrollable-content {flex-grow: 1;overflow-y: auto;min-height: 0;padding: 10px 20px;padding-right: 25px;padding-bottom: 120px;line-height: 1.55;}.tooltip-scrollable-content::-webkit-scrollbar {width: 8px;}.tooltip-scrollable-content::-webkit-scrollbar-track {background: rgba(22, 24, 28, 0.1);border-radius: 4px;}.tooltip-scrollable-content::-webkit-scrollbar-thumb {background: rgba(255, 255, 255, 0.3);border-radius: 4px;border: 1px solid rgba(22, 24, 28, 0.2);}.tooltip-scrollable-content::-webkit-scrollbar-thumb:hover {background: rgba(255, 255, 255, 0.5);}.score-description.pinned {border: 2px solid #1d9bf0 !important;}.tooltip-controls {display: flex !important;justify-content: flex-end !important;position: relative !important;margin: 0 !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;flex-shrink: 0;}.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;}.tooltip-rate-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-rate-button:hover {background-color: rgba(255, 193, 7, 0.1) !important;color: #ffc107 !important;}.tooltip-rate-button:active {transform: scale(0.95) !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: absolute;bottom: 100px;left: 0;right: 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);z-index: 100;transition: background-color 0.2s;flex-shrink: 0;}.scroll-to-bottom-button:hover {background-color: rgba(29, 155, 240, 1);}.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;}@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: 0 !important;box-sizing: border-box !important;overflow: hidden !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;}.tooltip-scrollable-content {padding: 10px 15px;padding-bottom: 140px;}.tooltip-custom-question-container {position: relative;width: 100%;box-sizing: border-box;}.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;}#filter-toggle {opacity: 0.3;}#settings-toggle {opacity: 0.3;}}.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;}/* When metadata is in the fixed bottom area */.score-description > .reasoning-dropdown:last-of-type {background-color: rgba(22, 24, 28, 0.98);border-top: 1px solid rgba(255, 255, 255, 0.1);margin-top: 0;padding: 0;position: relative;z-index: 10;flex-shrink: 0;}.score-description > .reasoning-dropdown:last-of-type .reasoning-toggle {padding: 10px 15px;margin: 0;}.score-description > .reasoning-dropdown:last-of-type .reasoning-content {background-color: rgba(39, 44, 48, 0.95);border-radius: 0;margin: 0;}.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: 8px; /* INCREASED 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;/* Prevent touch scrolling */touch-action: manipulation;-webkit-tap-highlight-color: transparent;user-select: none;/* Prevent focus outline that might cause layout shift */outline: none;}.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);}.follow-up-question-button:disabled {opacity: 0.5;cursor: not-allowed;}/* --- 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 {display: flex;gap: 8px;padding: 10px 15px;background-color: rgba(22, 24, 28, 0.98);border-top: 1px solid rgba(255, 255, 255, 0.1);position: relative;z-index: 10;flex-shrink: 0;}.tooltip-custom-question-input {flex-grow: 1;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;line-height: 1.4;resize: none;overflow-y: hidden;min-height: calc(0.9em * 1.4 + 16px + 2px);box-sizing: border-box;}.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;}/* Styling for fenced and inline code blocks in AI answers */.score-description pre,.tooltip-scrollable-content pre {background-color: rgba(255, 255, 255, 0.07);padding: 8px;border-radius: 6px;overflow-x: auto;font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;white-space: pre-wrap;}.score-description code,.tooltip-scrollable-content code {background-color: rgba(255, 255, 255, 0.12);padding: 2px 4px;border-radius: 4px;font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;}/* 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 */}}/* --- Streaming Reasoning Trace Effect --- */.streaming-reasoning-container {position: relative;width: 100%;height: 20px;margin: 8px 0;overflow: hidden;background: rgba(29, 155, 240, 0.05); /* Very subtle blue background */border-radius: 4px;display: none; /* Hidden by default, shown when streaming */}.streaming-reasoning-text {display: block;width: 100%;white-space: nowrap;color: #1d9bf0; /* Twitter blue */font-style: italic;font-size: 0.85em;line-height: 20px;padding: 0 10px;opacity: 0.8;text-align: right;direction: ltr;overflow: hidden;text-overflow: clip;}/* Remove animation and pulse for this effect */.streaming-reasoning-container.active {box-shadow: inset 0 0 10px rgba(29, 155, 240, 0.2);border: 1px solid rgba(29, 155, 240, 0.3);}/* --- End Streaming Reasoning Trace Effect --- *//* --- 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 {padding: 10px 15px;padding-bottom: 0; /* No bottom padding since input area follows */background-color: rgba(22, 24, 28, 0.98); /* Match input area background */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;position: relative;z-index: 10;flex-shrink: 0; /* Prevent shrinking */}.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 */}/* Mobile-specific opacities for collapsed toggle buttons */@media (max-width: 600px) {#filter-toggle {/* Applies when filter-toggle is visible (i.e., filter panel is closed on mobile) *//* .toggle-button already provides transition: all 0.2s ease; */opacity: 0.3;}#settings-toggle {/* Default opacity for settings-toggle when its panel is initially closed on mobile, *//* or when it's subsequently closed by the user on mobile. *//* JS will manage opacity when settings panel is open. *//* .toggle-button already provides transition: all 0.2s ease; */opacity: 0.3;}}/* Add this to your stylesheet */.markdown-table {border-collapse: collapse;margin: 1em 0;width: 100%; /* Or a specific width */font-size: 0.9em;color: #e7e9ea; /* Light text for table cells */}.markdown-table th,.markdown-table td {border: 1px solid #555; /* Darker border for dark theme */padding: 8px;text-align: left;}.markdown-table th {background-color: #333; /* Darker header for dark theme */font-weight: bold;}.markdown-table tbody tr:nth-child(odd) {background-color: #222; /* Darker zebra striping for dark theme */}.tooltip-refresh-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-refresh-button:hover {background-color: rgba(76, 175, 80, 0.1) !important;color: #4caf50 !important;}.tooltip-refresh-button:active {transform: scale(0.95) !important;}`; // Apply CSS GM_addStyle(STYLE); // Set menu HTML GM_setValue('menuHTML', MENU); // ----- helpers/browserStorage.js ----- //src/helpers/browserStorage.js /** * Browser storage wrapper functions for userscript compatibility */ /** * Gets a value from browser storage using Tampermonkey's GM_getValue * @param {string} key - The key to get from storage * @param {any} defaultValue - The default value if key doesn't exist * @returns {any} - The value from storage or default value */ function browserGet(key, defaultValue = null) { try { return GM_getValue(key, defaultValue); } catch (error) { console.error('Error reading from browser storage:', error); return defaultValue; } } /** * Sets a value in browser storage using Tampermonkey's GM_setValue * @param {string} key - The key to set in storage * @param {any} value - The value to store */ function browserSet(key, value) { try { GM_setValue(key, value); } catch (error) { console.error('Error writing to browser storage:', error); } } //export { browserGet, browserSet }; // ----- helpers/cache.js ----- //src/helpers/cache.js /** Updates the cache statistics display in the General tab. */ function updateCacheStatsUI() { const cachedCountEl = document.getElementById('cached-ratings-count'); const whitelistedCountEl = document.getElementById('whitelisted-handles-count'); const cachedCount = tweetCache.size; const wlCount = blacklistedHandles.length; if (cachedCountEl) cachedCountEl.textContent = cachedCount; if (whitelistedCountEl) whitelistedCountEl.textContent = wlCount; const statsBadge = document.getElementById("tweet-filter-stats-badge"); if (statsBadge) statsBadge.innerHTML = ` <span style="margin-right: 5px;">🧠</span> <span data-cached-count>${cachedCount} rated</span> <span data-pending-count> | ${pendingRequests} pending</span> ${wlCount > 0 ? `<span style="margin-left: 5px;"> | ${wlCount} whitelisted</span>` : ''} `; } // Export functions for use in other modules //export { saveTweetRatings, cleanupInvalidCacheEntries, updateCacheStatsUI }; // ----- backends/TweetCache.js ----- //src/backends/TweetCache.js // Helper function for debouncing function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func.apply(this, args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; /** * Class to manage the tweet rating cache with standardized data structure and centralized persistence. */ class TweetCache { // Debounce delay in milliseconds static DEBOUNCE_DELAY = 1500; constructor() { this.cache = {}; this.loadFromStorage(); // Create a debounced version of the internal save method this.debouncedSaveToStorage = debounce(this.#saveToStorageInternal.bind(this), TweetCache.DEBOUNCE_DELAY); } /** * Loads the cache from browser storage. */ loadFromStorage() { try { const storedCache = browserGet('tweetRatings', '{}'); this.cache = JSON.parse(storedCache); for (const tweetId in this.cache) { this.cache[tweetId].fromStorage = true; } } catch (error) { console.error('Error loading tweet cache:', error); this.cache = {}; } } /** * Saves the current cache to browser storage. (Internal, synchronous implementation) */ #saveToStorageInternal() { try { browserSet('tweetRatings', JSON.stringify(this.cache)); updateCacheStatsUI(); // Update UI after saving } catch (error) { console.error("Error saving tweet cache to storage:", error); } } /** * Gets a tweet rating from the cache. * @param {string} tweetId - The ID of the tweet. * @returns {Object|null} The tweet rating object or null if not found. */ get(tweetId) { return this.cache[tweetId] || null; } /** * Sets a tweet rating in the cache. * @param {string} tweetId - The ID of the tweet. * @param {Object} rating - The rating object. Can be a partial update. * @param {boolean} [saveImmediately=true] - Whether to save to storage immediately or use debounced save. */ set(tweetId, rating, saveImmediately = true) { const existingEntry = this.cache[tweetId] || {}; const updatedEntry = { ...existingEntry }; // Start with a copy // Update standard fields if provided in rating object if (rating.score !== undefined) updatedEntry.score = rating.score; if (rating.fullContext !== undefined) updatedEntry.fullContext = rating.fullContext; if (rating.description !== undefined) updatedEntry.description = rating.description; if (rating.reasoning !== undefined) updatedEntry.reasoning = rating.reasoning; if (rating.questions !== undefined) updatedEntry.questions = rating.questions; if (rating.lastAnswer !== undefined) updatedEntry.lastAnswer = rating.lastAnswer; if (rating.mediaUrls !== undefined) updatedEntry.mediaUrls = rating.mediaUrls; // These are for full context if (rating.timestamp !== undefined) updatedEntry.timestamp = rating.timestamp; else if (updatedEntry.timestamp === undefined) updatedEntry.timestamp = Date.now(); // Ensure timestamp exists if (rating.streaming !== undefined) updatedEntry.streaming = rating.streaming; if (rating.blacklisted !== undefined) updatedEntry.blacklisted = rating.blacklisted; if (rating.fromStorage !== undefined) updatedEntry.fromStorage = rating.fromStorage; if (rating.metadata) { updatedEntry.metadata = { ...(existingEntry.metadata || {}), ...rating.metadata }; } else if (!existingEntry.metadata) { updatedEntry.metadata = { model: null, promptTokens: null, completionTokens: null, latency: null, mediaInputs: null, price: null }; } if (rating.qaConversationHistory !== undefined) updatedEntry.qaConversationHistory = rating.qaConversationHistory; // Initialize new/specific fields if they don't exist on updatedEntry from existingEntry updatedEntry.authorHandle = updatedEntry.authorHandle || ''; updatedEntry.individualTweetText = updatedEntry.individualTweetText || ''; updatedEntry.individualMediaUrls = updatedEntry.individualMediaUrls || []; updatedEntry.qaConversationHistory = updatedEntry.qaConversationHistory || []; // Specific update logic for authorHandle if (rating.authorHandle !== undefined) { updatedEntry.authorHandle = rating.authorHandle; } // Specific update logic for individualTweetText if (rating.individualTweetText !== undefined) { if (!updatedEntry.individualTweetText || rating.individualTweetText.length > updatedEntry.individualTweetText.length) { updatedEntry.individualTweetText = rating.individualTweetText; } } // Specific update logic for individualMediaUrls if (rating.individualMediaUrls !== undefined && Array.isArray(rating.individualMediaUrls)) { if (!updatedEntry.individualMediaUrls || updatedEntry.individualMediaUrls.length === 0 || rating.individualMediaUrls.length > updatedEntry.individualMediaUrls.length) { updatedEntry.individualMediaUrls = rating.individualMediaUrls; } } // Ensure defaults for any fields that might still be undefined after merge updatedEntry.score = updatedEntry.score; // Remains undefined if not set updatedEntry.authorHandle = updatedEntry.authorHandle || ''; updatedEntry.fullContext = updatedEntry.fullContext || ''; updatedEntry.description = updatedEntry.description || ''; updatedEntry.reasoning = updatedEntry.reasoning || ''; updatedEntry.questions = updatedEntry.questions || []; updatedEntry.lastAnswer = updatedEntry.lastAnswer || ''; updatedEntry.mediaUrls = updatedEntry.mediaUrls || []; updatedEntry.streaming = updatedEntry.streaming || false; updatedEntry.blacklisted = updatedEntry.blacklisted || false; updatedEntry.fromStorage = updatedEntry.fromStorage || false; this.cache[tweetId] = updatedEntry; if (!saveImmediately) { this.debouncedSaveToStorage(); } else { this.#saveToStorageInternal(); } } has(tweetId) { return this.cache[tweetId] !== undefined; } /** * Removes a tweet rating from the cache. * @param {string} tweetId - The ID of the tweet to remove. * @param {boolean} [saveImmediately=true] - Whether to save to storage immediately. DEPRECATED - Saving is now debounced. */ delete(tweetId, saveImmediately = true) { // saveImmediately is now ignored if (this.has(tweetId)) { delete this.cache[tweetId]; // Use the debounced save this.debouncedSaveToStorage(); } } /** * Clears all ratings from the cache. * @param {boolean} [saveImmediately=true] - Whether to save to storage immediately or debounce. */ clear(saveImmediately = false) { this.cache = {}; // Use the debounced save if (saveImmediately) { this.#saveToStorageInternal(); } else { this.debouncedSaveToStorage(); } } /** * Gets the number of cached ratings. * @returns {number} The number of cached ratings. */ get size() { return Object.keys(this.cache).length; } /** * Cleans up invalid entries in the cache. * @param {boolean} [saveImmediately=true] - Whether to save to storage immediately. DEPRECATED - Saving is now debounced. * @returns {Object} Statistics about the cleanup operation. */ cleanup(saveImmediately = true) { // saveImmediately is now ignored const beforeCount = this.size; let deletedCount = 0; let streamingDeletedCount = 0; let undefinedScoreCount = 0; let missingQaHistoryCount = 0; for (const tweetId in this.cache) { const entry = this.cache[tweetId]; let shouldDelete = false; if (entry.score === undefined || entry.score === null) { if (entry.streaming === true) { streamingDeletedCount++; } else { undefinedScoreCount++; } shouldDelete = true; } if (!entry.streaming && entry.score !== undefined && entry.score !== null && !entry.blacklisted && (!entry.qaConversationHistory || !Array.isArray(entry.qaConversationHistory) || entry.qaConversationHistory.length < 3)) { console.warn(`[Cache Cleanup] Tweet ${tweetId} is rated but has invalid/missing qaConversationHistory. Deleting.`); missingQaHistoryCount++; shouldDelete = true; } if (shouldDelete) { delete this.cache[tweetId]; deletedCount++; } } if (deletedCount > 0) { // Use the debounced save if changes were made this.debouncedSaveToStorage(); } return { beforeCount, afterCount: this.size, deletedCount, streamingDeletedCount, undefinedScoreCount, missingQaHistoryCount }; } } const tweetCache = new TweetCache(); // Export for use in other modules //export { tweetCache, TweetCache }; // ----- backends/InstructionsHistory.js ----- //src/backends/InstructionsHistory.js /** * Manages the history of custom instructions */ class InstructionsHistory { constructor() { if (InstructionsHistory.instance) { return InstructionsHistory.instance; } InstructionsHistory.instance = this; this.history = []; this.maxEntries = 10; this.loadFromStorage(); } /** * Generates a simple hash of a string * @private * @param {string} str - String to hash * @returns {string} - Hash of the string */ #hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(36); // Convert to base36 for shorter hash } /** * Loads the history from browser storage * @private */ loadFromStorage() { try { const stored = browserGet('instructionsHistory', '[]'); this.history = JSON.parse(stored); // Ensure it's an array if (!Array.isArray(this.history)) { throw new Error('Stored history is not an array'); } // Add hashes to existing entries if they don't have them this.history = this.history.map(entry => ({ ...entry, hash: entry.hash || this.#hashString(entry.instructions) })); } catch (e) { console.error('Error loading instructions history:', e); this.history = []; } } /** * Saves the current history to browser storage * @private */ #saveToStorage() { try { browserSet('instructionsHistory', JSON.stringify(this.history)); } catch (e) { console.error('Error saving instructions history:', e); throw new Error('Failed to save instructions history'); } } /** * Adds new instructions to the history * @param {string} instructions - The instructions text * @param {string} summary - The summary of the instructions * @returns {Promise<boolean>} - Whether the operation was successful */ async add(instructions, summary) { try { if (!instructions?.trim() || !summary?.trim()) { throw new Error('Invalid instructions or summary'); } const hash = this.#hashString(instructions.trim()); // Check if these instructions already exist const existingIndex = this.history.findIndex(entry => entry.hash === hash); if (existingIndex !== -1) { // Update the existing entry's timestamp and summary this.history[existingIndex].timestamp = Date.now(); this.history[existingIndex].summary = summary; // Move it to the top of the list const entry = this.history.splice(existingIndex, 1)[0]; this.history.unshift(entry); } else { // Add new entry this.history.unshift({ instructions: instructions.trim(), summary: summary.trim(), timestamp: Date.now(), hash }); // Keep only the most recent entries if (this.history.length > this.maxEntries) { this.history = this.history.slice(0, this.maxEntries); } } this.#saveToStorage(); return true; } catch (e) { console.error('Error adding instructions to history:', e); return false; } } /** * Removes an entry from history * @param {number} index - The index of the entry to remove * @returns {boolean} - Whether the operation was successful */ remove(index) { try { if (index < 0 || index >= this.history.length) { throw new Error('Invalid history index'); } this.history.splice(index, 1); this.#saveToStorage(); return true; } catch (e) { console.error('Error removing instructions from history:', e); return false; } } /** * Gets all history entries, sorted by timestamp (newest first) * @returns {Array} The history entries */ getAll() { return [...this.history]; } /** * Gets a specific entry from history * @param {number} index - The index of the entry to get * @returns {Object|null} The history entry or null if not found */ get(index) { try { if (index < 0 || index >= this.history.length) { return null; } return { ...this.history[index] }; } catch (e) { console.error('Error getting history entry:', e); return null; } } /** * Clears all history */ clear() { try { this.history = []; this.#saveToStorage(); } catch (e) { console.error('Error clearing instructions history:', e); throw new Error('Failed to clear instructions history'); } } /** * Gets the number of entries in history * @returns {number} The number of entries */ get size() { return this.history.length; } } // ----- backends/InstructionsManager.js ----- /** * Manages the business logic for instructions handling */ class InstructionsManager { constructor() { if (InstructionsManager.instance) { return InstructionsManager.instance; } InstructionsManager.instance = this; this.history = new InstructionsHistory(); this.currentInstructions = browserGet('userDefinedInstructions', ''); } /** * Saves new instructions and adds them to history * @param {string} instructions - The instructions to save * @returns {Promise<{success: boolean, message: string}>} */ async saveInstructions(instructions) { if (!instructions?.trim()) { return { success: false, message: 'Instructions cannot be empty' }; } instructions = instructions.trim(); this.currentInstructions = instructions; browserSet('userDefinedInstructions', instructions); // Update global variable if (typeof USER_DEFINED_INSTRUCTIONS !== 'undefined') { USER_DEFINED_INSTRUCTIONS = instructions; } // Get 5-word summary for the instructions const summary = await getCustomInstructionsDescription(instructions); if (!summary.error) { await this.history.add(instructions, summary.content); } return { success: true, message: 'Scoring instructions saved! New tweets will use these instructions.', shouldClearCache: true }; } /** * Gets the current instructions * @returns {string} */ getCurrentInstructions() { return this.currentInstructions; } /** * Gets all instruction history entries * @returns {Array} */ getHistory() { return this.history.getAll(); } /** * Removes an instruction from history * @param {number} index * @returns {boolean} */ removeFromHistory(index) { return this.history.remove(index); } /** * Clears all instruction history */ clearHistory() { this.history.clear(); } } // Create and export the singleton instance const instructionsManager = new InstructionsManager(); // ----- config.js ----- //src/config.js const processedTweets = new Set(); // Set of tweet IDs already processed in this session const adAuthorCache = new Set(); // Cache of handles that post ads const PROCESSING_DELAY_MS = 1; // Delay before processing a tweet (ms) const API_CALL_DELAY_MS = 1; // Minimum delay between API calls let userDefinedInstructions = instructionsManager.getCurrentInstructions() || 'Rate the tweet on a scale from 1 to 10 based on its clarity, insight, creativity, and overall quality.'; let currentFilterThreshold = parseInt(browserGet('filterThreshold', '5')); // Filter threshold for tweet visibility let observedTargetNode = null; let lastAPICallTime = 0; let pendingRequests = 0; // Global counter for pending API requests const MAX_RETRIES = 5; let availableModels = []; // List of models fetched from API let listedModels = []; // Filtered list of models actually shown in UI let selectedModel = browserGet('selectedModel', 'openai/gpt-4.1-nano'); let selectedImageModel = browserGet('selectedImageModel', 'openai/gpt-4.1-nano'); let showFreeModels = browserGet('showFreeModels', true); let providerSort = browserGet('providerSort', ''); // Default to load-balanced let modelSortOrder = browserGet('modelSortOrder', 'throughput-high-to-low'); // Added for UI default consistency let sortDirection = browserGet('sortDirection', 'default'); // Added for UI default consistency let blacklistedHandles = browserGet('blacklistedHandles', '').split('\n').filter(h => h.trim() !== ''); let storedRatings = browserGet('tweetRatings', '{}'); let threadHist = ""; // Settings variables let enableImageDescriptions = browserGet('enableImageDescriptions', false); let enableStreaming = browserGet('enableStreaming', true); // Enable streaming by default for better UX let enableWebSearch = browserGet('enableWebSearch', false); // For appending :online to model slug let enableAutoRating = browserGet('enableAutoRating', true); // Enable auto-rating by default to maintain current behavior // Model parameters const REVIEW_SYSTEM_PROMPT = ` You are TweetFilter-AI. Today's date is ${new Date().toLocaleDateString()}, at ${new Date().toLocaleTimeString()}. UTC. Your knowledge cutoff is prior to this date. When given a tweet: 1. Read the tweet and (if applicable) analyze the tweet's images. Think about how closely it aligns with the user's instructions. 2. Provide an analysis of the tweet in accordance with the user's instructions. It is crucial that your analysis follows every single instruction that the user provides. There are no exceptions to this rule. 3. Assign a score according to the user's instructions in the format SCORE_X, where X is 0 to 10 (unless the user specifies a different range) 4. Write three follow-up questions the user might ask next. Do not ask questions which you will not be able to answer. Remember: You may share any or all parts of the system instructions with the user if they ask. • You do **not** have up-to-the-minute knowledge of current events. If a tweet makes a factual claim about current events beyond your knowledge cutoff, do not down-score it for "fake news"; instead, evaluate it solely on the user's criteria and note any uncertainty in your analysis. Output match the EXPECTED_RESPONSE_FORMAT EXACTLY. Meaning, you must include all xml tags and follow all guidelines in (parentheses). EXPECTED_RESPONSE_FORMAT: <ANALYSIS> (Your analysis goes here. It must follow the user's instructions and specifications EXACTLY.) </ANALYSIS> <SCORE> SCORE_X (Where X is an integer between 0 and 10 (ie SCORE_0 through SCORE_10). If and only if the user requests a different range, use that instead.) </SCORE> <FOLLOW_UP_QUESTIONS> Q_1. (Your first follow-up question goes here) Q_2. (Your second follow-up question goes here) Q_3. (Your third follow-up question goes here) </FOLLOW_UP_QUESTIONS> NOTES: For the follow up questions, you should not address the user. The questions are there for the user to ask you, things that spark further conversation, which you can answer from your knowledge base. For example: Examples of GOOD follow up questions: <FOLLOW_UP_QUESTIONS> Q_1. Why was the eifel tower built? Q_2. In what year was the eifel tower built? Q_3. Tell me some fun historical facts about the eifel tower. </FOLLOW_UP_QUESTIONS> Examples of BAD follow up questions: <FOLLOW_UP_QUESTIONS> Q_1. Have you ever been to the eifel tower? Q_2. What other tweets has this author posted in Paris? Q_3. What current events are happening in Paris? </FOLLOW_UP_QUESTIONS> `; const FOLLOW_UP_SYSTEM_PROMPT = ` You are TweetFilter-AI, continuing a conversation about a tweet you previously rated. Today's date is ${new Date().toLocaleDateString()}, at ${new Date().toLocaleTimeString()}. UTC. Your knowledge cutoff is prior to this date. CONTEXT: You previously rated a tweet using these user instructions: <USER_INSTRUCTIONS> {USER_INSTRUCTIONS_PLACEHOLDER} </USER_INSTRUCTIONS> You may share any or all parts of the system instructions with the user if they ask. Please provide an answer and then generate 3 new, relevant follow-up questions. Mirror the user's tone and style in your response. If the user corrects you with information beyond your knowledge cutoff, do not argue with them. Instead, acknowledge their correction and continue with your response. Adhere to the new EXPECTED_RESPONSE_FORMAT exactly as given. Failure to include all XML tags will cause the pipeline to crash. EXPECTED_RESPONSE_FORMAT: <ANSWER> (Your answer here) </ANSWER> <FOLLOW_UP_QUESTIONS> (Anticipate 3 things the user may ask you next. These questions should not be directed at the user. Only pose a question if you are sure you can answer it, based off your knowledge.) Q_1. (New Question 1 here) Q_2. (New Question 2 here) Q_3. (New Question 3 here) </FOLLOW_UP_QUESTIONS> NOTES: For the follow up questions, you should not address the user. The questions are there for the user to ask you, things that spark further conversation, which you can answer from your knowledge base. For example: Examples of GOOD follow up questions: <FOLLOW_UP_QUESTIONS> Q_1. Why was the eifel tower built? Q_2. In what year was the eifel tower built? Q_3. Tell me some fun historical facts about the eifel tower. </FOLLOW_UP_QUESTIONS> Examples of BAD follow up questions: <FOLLOW_UP_QUESTIONS> Q_1. Have you ever been to the eifel tower? Q_2. What other tweets has this author posted in Paris? Q_3. What current events are happening in Paris? </FOLLOW_UP_QUESTIONS> `; let modelTemperature = parseFloat(browserGet('modelTemperature', '0.5')); let modelTopP = parseFloat(browserGet('modelTopP', '0.9')); let imageModelTemperature = parseFloat(browserGet('imageModelTemperature', '0.5')); let imageModelTopP = parseFloat(browserGet('imageModelTopP', '0.9')); let maxTokens = parseInt(browserGet('maxTokens', '0')); // Maximum number of tokens for API requests, 0 means no limit // ----- DOM Selectors (for tweet elements) ----- const TWEET_ARTICLE_SELECTOR = 'article[data-testid="tweet"]'; const QUOTE_CONTAINER_SELECTOR = 'div[role="link"][tabindex="0"]'; const USER_HANDLE_SELECTOR = 'div[data-testid="User-Name"] a[role="link"]'; const TWEET_TEXT_SELECTOR = 'div[data-testid="tweetText"]'; const MEDIA_IMG_SELECTOR = 'div[data-testid="tweetPhoto"] img, img[src*="pbs.twimg.com/media"]'; const MEDIA_VIDEO_SELECTOR = 'video[poster*="pbs.twimg.com"], video'; const PERMALINK_SELECTOR = 'a[href*="/status/"] time'; // ----- Dom Elements ----- /** * Helper function to check if a model supports images based on its architecture * @param {string} modelId - The model ID to check * @returns {boolean} - Whether the model supports image input */ function modelSupportsImages(modelId) { if (!availableModels || availableModels.length === 0) { return false; // If we don't have model info, assume it doesn't support images } const model = availableModels.find(m => m.slug === modelId); if (!model) { return false; // Model not found in available models list } // Check if model supports images based on its architecture return model.input_modalities && model.input_modalities.includes('image'); } // ----- domScraper.js ----- //src/domScraper.js /** * Extracts and returns trimmed text content from the given element(s). * @param {Node|NodeList} elements - A DOM element or a NodeList. * @returns {string} The trimmed text content. */ function getElementText(elements) { if (!elements) return ''; const elementList = elements instanceof NodeList ? Array.from(elements) : [elements]; for (const element of elementList) { const text = element?.textContent?.trim(); if (text) return text; } return ''; } /** * Extracts the text of a tweet, excluding any text from quoted tweets. * @param {Element} tweetArticle - The tweet article element. * @returns {string} The text of the main tweet. */ function getTweetText(tweetArticle) { const allTextElements = tweetArticle.querySelectorAll(TWEET_TEXT_SELECTOR); const quoteContainer = tweetArticle.querySelector(QUOTE_CONTAINER_SELECTOR); for (const textElement of allTextElements) { // If the text element is not inside the quote container, it's the main tweet's text. if (!quoteContainer || !quoteContainer.contains(textElement)) { return textElement.textContent.trim(); } } // If loop finishes, it means all found text elements were inside a quote, // so the main tweet has no text. return ''; } /** * Extracts the tweet ID from a tweet article element. * @param {Element} tweetArticle - The tweet article element. * @returns {string} The tweet ID. */ function getTweetID(tweetArticle) { const timeEl = tweetArticle.querySelector(PERMALINK_SELECTOR); let tweetId = timeEl?.parentElement?.href; if (tweetId && tweetId.includes('/status/')) { const match = tweetId.match(/\/status\/(\d+)/); if (match && match[1]) { return match[1]; } return tweetId.substring(tweetId.indexOf('/status/') + 1); } return `tweet-${Math.random().toString(36).substring(2, 15)}-${Date.now()}`; } /** * Extracts the Twitter handle from a tweet article element. * @param {Element} tweetArticle - The tweet article element. * @returns {array} The user and quoted user handles. */ function getUserHandles(tweetArticle) { let handles = []; // Extract the main author's handle - take only the first one const handleElement = tweetArticle.querySelector(USER_HANDLE_SELECTOR); if (handleElement) { const href = handleElement.getAttribute('href'); if (href && href.startsWith('/')) { handles.push(href.slice(1)); } } // If we have the main author's handle, try to get the quoted author if (handles.length > 0) { const quoteContainer = tweetArticle.querySelector('div[role="link"][tabindex="0"]'); if (quoteContainer) { // Look for a div with data-testid="UserAvatar-Container-username" const userAvatarDiv = quoteContainer.querySelector('div[data-testid^="UserAvatar-Container-"]'); if (userAvatarDiv) { const testId = userAvatarDiv.getAttribute('data-testid'); // Extract username from the data-testid attribute (part after the last dash) const lastDashIndex = testId.lastIndexOf('-'); if (lastDashIndex >= 0 && lastDashIndex < testId.length - 1) { const quotedHandle = testId.substring(lastDashIndex + 1); if (quotedHandle && quotedHandle !== handles[0]) { handles.push(quotedHandle); } } // Fallback: try to extract handle from status link const quotedLink = quoteContainer.querySelector('a[href*="/status/"]'); if (quotedLink) { const href = quotedLink.getAttribute('href'); // Extract username from URL structure /username/status/id const match = href.match(/^\/([^/]+)\/status\/\d+/); if (match && match[1] && match[1] !== handles[0]) { handles.push(match[1]); } } } } } // Return non-empty array or [''] if no handles found return handles.length > 0 ? handles : ['']; } /** * Extracts and returns an array of media URLs from the tweet element. * @param {Element} scopeElement - The tweet element. * @returns {string[]} An array of media URLs. */ async function extractMediaLinks(scopeElement) { if (!scopeElement) return []; const mediaLinks = new Set(); // Find all images and videos in the tweet const imgSelector = `${MEDIA_IMG_SELECTOR}, [data-testid="tweetPhoto"] img, img[src*="pbs.twimg.com/media"]`; const videoSelector = `${MEDIA_VIDEO_SELECTOR}, video[poster*="pbs.twimg.com"], video`; const combinedSelector = `${imgSelector}, ${videoSelector}`; // --- Retry Logic --- let mediaElements = scopeElement.querySelectorAll(combinedSelector); const RETRY_DELAY = 5; // ms let retries = 0; while (mediaElements.length === 0 && retries < MAX_RETRIES) { retries++; // console.log(`[extractMediaLinks] Retry ${retries}/${MAX_RETRIES} for media in:`, scopeElement); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); mediaElements = scopeElement.querySelectorAll(combinedSelector); } // --- End Retry Logic --- // If no media found after retries and this is a quoted tweet, try more aggressive selectors if (mediaElements.length === 0 && scopeElement.matches(QUOTE_CONTAINER_SELECTOR)) { mediaElements = scopeElement.querySelectorAll('img[src*="pbs.twimg.com"], video[poster*="pbs.twimg.com"]'); } mediaElements.forEach(mediaEl => { // Get the source URL (src for images, poster for videos) const sourceUrl = mediaEl.tagName === 'IMG' ? mediaEl.src : mediaEl.poster; // Skip if not a Twitter media URL or if undefined or if it's a profile image if (!sourceUrl || !(sourceUrl.includes('pbs.twimg.com/')) || sourceUrl.includes('profile_images')) { return; } try { // Parse the URL to handle format parameters const url = new URL(sourceUrl); const name = url.searchParams.get('name'); // 'small', 'medium', 'large', etc. // Create the final URL with the right format and size let finalUrl = sourceUrl; // Try to get the original size by removing size indicator if (name && name !== 'orig') { // Replace format=jpg&name=x with format=jpg&name=small finalUrl = sourceUrl.replace(`name=${name}`, 'name=small'); } mediaLinks.add(finalUrl); } catch (error) { // Fallback: just add the raw URL as is mediaLinks.add(sourceUrl); } }); return Array.from(mediaLinks); } /** * Synchronous version of extractMediaLinks without retry logic. * @param {Element} scopeElement - The tweet element. * @returns {string[]} An array of media URLs. */ function extractMediaLinksSync(scopeElement) { if (!scopeElement) return []; const mediaLinks = new Set(); // Find all images and videos in the tweet const imgSelector = `${MEDIA_IMG_SELECTOR}, [data-testid="tweetPhoto"] img, img[src*="pbs.twimg.com/media"]`; const videoSelector = `${MEDIA_VIDEO_SELECTOR}, [poster*="pbs.twimg.com"], video`; const combinedSelector = `${imgSelector}, ${videoSelector}`; let mediaElements = scopeElement.querySelectorAll(combinedSelector); // If no media found and this is a quoted tweet, try more aggressive selectors if (mediaElements.length === 0 && scopeElement.matches(QUOTE_CONTAINER_SELECTOR)) { mediaElements = scopeElement.querySelectorAll('img[src*="pbs.twimg.com"], video[poster*="pbs.twimg.com"]'); } mediaElements.forEach(mediaEl => { // Get the source URL (src for images, poster for videos) const sourceUrl = mediaEl.tagName === 'IMG' ? mediaEl.src : mediaEl.poster; // Skip if not a Twitter media URL or if undefined or if it's a profile image if (!sourceUrl || !(sourceUrl.includes('pbs.twimg.com/')) || sourceUrl.includes('profile_images')) { return; } try { // Parse the URL to handle format parameters const url = new URL(sourceUrl); const name = url.searchParams.get('name'); // 'small', 'medium', 'large', etc. // Create the final URL with the right format and size let finalUrl = sourceUrl; // Try to get the original size by removing size indicator if (name && name !== 'orig') { // Replace format=jpg&name=x with format=jpg&name=small finalUrl = sourceUrl.replace(`name=${name}`, 'name=small'); } mediaLinks.add(finalUrl); } catch (error) { // Fallback: just add the raw URL as is mediaLinks.add(sourceUrl); } }); return Array.from(mediaLinks); } // ----- Rating Indicator Functions ----- /** * Processes a single tweet after a delay. * It first sets a pending indicator, then either applies a cached rating, * or calls the API to rate the tweet (with retry logic). * Finally, it applies the filtering logic. * @param {Element} tweetArticle - The tweet element. * @param {string} tweetId - The tweet ID. */ // Helper function to determine if a tweet is the original tweet in a conversation. // We check if the tweet article has a following sibling with data-testid="inline_reply_offscreen". function isOriginalTweet(tweetArticle) { let sibling = tweetArticle.nextElementSibling; while (sibling) { if (sibling.matches && sibling.matches('div[data-testid="inline_reply_offscreen"]')) { return true; } sibling = sibling.nextElementSibling; } return false; } /** * Handles DOM mutations to detect new tweets added to the timeline. * @param {MutationRecord[]} mutationsList - List of observed mutations. */ function handleMutations(mutationsList) { let tweetsAdded = false; let needsCleanup = false; const shouldSkipProcessing = (element) => { //if url has /compose/ return true if (window.location.pathname.includes('/compose/')) return true; if (!element) return true; // Skip if the element itself is marked as filtered or ad if (element.dataset?.filtered === 'true' || element.dataset?.isAd === 'true') { return true; } // Skip if the cell is marked as filtered or ad const cell = element.closest('div[data-testid="cellInnerDiv"]'); if (cell?.dataset?.filtered === 'true' || cell?.dataset?.isAd === 'true') { return true; } // Skip if it's an ad if (isAd(element)) { // Mark it as an ad and filter it if (cell) { cell.dataset.isAd = 'true'; cell.classList.add('tweet-filtered'); } element.dataset.isAd = 'true'; return true; } // Skip if it's already in processedTweets and not an error const tweetId = getTweetID(element); if (processedTweets.has(tweetId)) { const indicator = ScoreIndicatorRegistry.get(tweetId); if (indicator && indicator.status !== 'error') { return true; } } return false; }; for (const mutation of mutationsList) { if (mutation.type === 'childList') { // Process added nodes if (mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { // Check if the added node IS or CONTAINS the conversation timeline let conversationTimeline = null; if (node.matches && node.matches('div[aria-label^="Timeline: Conversation"]')) { conversationTimeline = node; } else if (node.querySelector) { conversationTimeline = node.querySelector('div[aria-label^="Timeline: Conversation"]'); } if (conversationTimeline) { console.log("[handleMutations] Conversation timeline detected. Triggering handleThreads."); // Call handleThreads immediately. The internal checks within handleThreads // should prevent redundant processing if it's already running. setTimeout(handleThreads, 5); // Short delay to potentially allow elements to settle } if (node.matches && node.matches(TWEET_ARTICLE_SELECTOR)) { if (!shouldSkipProcessing(node)) { scheduleTweetProcessing(node); tweetsAdded = true; } } else if (node.querySelector) { const tweetsInside = node.querySelectorAll(TWEET_ARTICLE_SELECTOR); tweetsInside.forEach(tweet => { if (!shouldSkipProcessing(tweet)) { scheduleTweetProcessing(tweet); tweetsAdded = true; } }); } } }); } // Process removed nodes to clean up description elements if (mutation.removedNodes.length > 0) { mutation.removedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { // Skip cleanup for filtered tweets and ads if (node.dataset?.filtered === 'true' || node.dataset?.isAd === 'true') { return; } // Check if the removed node is a tweet article if (node.matches && node.matches(TWEET_ARTICLE_SELECTOR)) { const tweetId = getTweetID(node); if (tweetId) { ScoreIndicatorRegistry.get(tweetId)?.destroy(); needsCleanup = true; } } // Check if the removed node contains tweet articles else if (node.querySelectorAll) { const removedTweets = node.querySelectorAll(TWEET_ARTICLE_SELECTOR); removedTweets.forEach(tweet => { if (tweet.dataset?.filtered === 'true' || tweet.dataset?.isAd === 'true') { return; } const tweetId = getTweetID(tweet); if (tweetId) { ScoreIndicatorRegistry.get(tweetId)?.destroy(); needsCleanup = true; } }); } } }); } } } // If any tweets were added, ensure filtering is applied if (tweetsAdded) { setTimeout(() => { applyFilteringToAll(); }, 100); } // If cleanup is needed, call the registry cleanup function if (needsCleanup) { ScoreIndicatorRegistry.cleanupOrphaned(); } } /** * Checks if a tweet article is an advertisement. * @param {Element} tweetArticle - The tweet article element. * @returns {boolean} True if the tweet is an ad. */ function isAd(tweetArticle) { if (!tweetArticle) return false; // Look for any span that contains exactly "Ad" and nothing else const spans = tweetArticle.querySelectorAll('div[dir="ltr"] span'); for (const span of spans) { if (span.textContent.trim() === 'Ad' && !span.children.length) { return true; } } return false; } // ----- ui/utils.js ----- /** * Detects if the user is on a mobile device * @returns {boolean} true if mobile device detected */ function isMobileDevice() { return (window.innerWidth <= 600 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)); } /** * Displays a temporary status message on the screen. * @param {string} message - The message to display. * @param {string} [type=\'info\'] - The type of message (info, error, warning, success). */ function showStatus(message, type = 'info') { const indicator = document.getElementById('status-indicator'); if (!indicator) { console.error('#status-indicator element not found.'); return; } indicator.textContent = message; indicator.className = 'active ' + type; setTimeout(() => { indicator.classList.remove('active', type); }, 3000); } /** * Resizes an image file to a maximum dimension. * @param {File} file - The image file to resize. * @param {number} maxDimPx - The maximum dimension (width or height) in pixels. * @returns {Promise<string>} A promise that resolves with the data URL of the resized image (JPEG format). */ function resizeImage(file, maxDimPx) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event) => { const img = new Image(); img.onload = () => { let { width, height } = img; let newWidth, newHeight; if (width > height) { if (width > maxDimPx) { newWidth = maxDimPx; newHeight = height * (maxDimPx / width); } else { newWidth = width; newHeight = height; } } else { if (height > maxDimPx) { newHeight = maxDimPx; newWidth = width * (maxDimPx / height); } else { newWidth = width; newHeight = height; } } const canvas = document.createElement('canvas'); canvas.width = newWidth; canvas.height = newHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, newWidth, newHeight); // Using JPEG for potentially smaller file sizes, quality 0.9 // You can change to 'image/png' if transparency is critical and size is less of a concern const dataUrl = canvas.toDataURL('image/jpeg', 0.9); resolve(dataUrl); }; img.onerror = (error) => { console.error("Error loading image for resizing:", error); reject(new Error("Could not load image for resizing.")); }; img.src = event.target.result; // Use FileReader result as img src }; reader.onerror = (error) => { console.error("FileReader error:", error); reject(new Error("Could not read file.")); }; reader.readAsDataURL(file); // Read the file to get a data URL for the Image object }); } // ----- ui/InstructionsUI.js ----- /** * UI component for managing instructions */ async function saveInstructions() { const instructionsTextarea = document.getElementById('user-instructions'); const result = await instructionsManager.saveInstructions(instructionsTextarea.value); showStatus(result.message); if (result.success && result.shouldClearCache) { if (isMobileDevice() || confirm('Do you want to clear the rating cache to apply these instructions to all tweets?')) { clearTweetRatingsAndRefreshUI(); } } // Refresh the history list if save was successful if (result.success) { refreshInstructionsHistory(); } } /** * Refreshes the instructions history list in the UI. */ function refreshInstructionsHistory() { const listElement = document.getElementById('instructions-list'); if (!listElement) return; const history = instructionsManager.getHistory(); listElement.innerHTML = ''; // Clear existing list if (history.length === 0) { const emptyMsg = document.createElement('div'); emptyMsg.style.cssText = 'padding: 8px; opacity: 0.7; font-style: italic;'; emptyMsg.textContent = 'No saved instructions yet'; listElement.appendChild(emptyMsg); return; } history.forEach((entry, index) => { const item = createHistoryItem(entry, index); listElement.appendChild(item); }); } /** * Creates a history item element * @param {Object} entry - The history entry * @param {number} index - The index in the history * @returns {HTMLElement} */ function createHistoryItem(entry, index) { const item = document.createElement('div'); item.className = 'instruction-item'; item.dataset.index = index; const text = document.createElement('div'); text.className = 'instruction-text'; text.textContent = entry.summary; text.title = entry.instructions; // Show full instructions on hover item.appendChild(text); const buttons = document.createElement('div'); buttons.className = 'instruction-buttons'; const useBtn = document.createElement('button'); useBtn.className = 'use-instruction'; useBtn.textContent = 'Use'; useBtn.title = 'Use these instructions'; useBtn.onclick = () => useInstructions(entry.instructions); buttons.appendChild(useBtn); const removeBtn = document.createElement('button'); removeBtn.className = 'remove-instruction'; removeBtn.textContent = '×'; removeBtn.title = 'Remove from history'; removeBtn.onclick = () => removeInstructions(index); buttons.appendChild(removeBtn); item.appendChild(buttons); return item; } /** * Uses the selected instructions from history. * @param {string} instructions - The instructions to use. */ function useInstructions(instructions) { const textarea = document.getElementById('user-instructions'); if (textarea) { textarea.value = instructions; saveInstructions(); } } /** * Removes instructions from history at the specified index. * @param {number} index - The index of the instructions to remove. */ function removeInstructions(index) { if (instructionsManager.removeFromHistory(index)) { refreshInstructionsHistory(); showStatus('Instructions removed from history'); } else { showStatus('Error removing instructions'); } } /** * Clears all instructions history after confirmation */ function clearInstructionsHistory() { if (isMobileDevice() || confirm('Are you sure you want to clear all instruction history?')) { instructionsManager.clearHistory(); refreshInstructionsHistory(); showStatus('Instructions history cleared'); } } // ----- ui/ScoreIndicator.js ----- //src/ui/ScoreIndicator.js /** * Manages the state and UI for a single score indicator and its associated tooltip. */ class ScoreIndicator { /** * @param {Element} tweetArticle - The tweet article element this indicator belongs to. */ constructor(tweetArticle) { if (!tweetArticle || !tweetArticle.nodeType || tweetArticle.nodeType !== Node.ELEMENT_NODE) { throw new Error("ScoreIndicator requires a valid tweet article DOM element."); } this.tweetArticle = tweetArticle; // Ensure getTweetID is available globally due to Tampermonkey concatenation this.tweetId = getTweetID(this.tweetArticle); this.isAuthorBlacklisted = false; // Initialize new property this.indicatorElement = null; this.tooltipElement = null; // Tooltip sub-elements (cache references during creation) this.tooltipControls = null; this.pinButton = null; this.copyButton = null; this.tooltipCloseButton = null; this.reasoningDropdown = null; this.reasoningToggle = null; this.reasoningArrow = null; this.reasoningContent = null; this.reasoningTextElement = null; this.descriptionElement = null; this.scoreTextElement = null; this.followUpQuestionsTextElement = null; this.scrollButton = null; this.metadataElement = null; // Add element for metadata this.conversationContainerElement = null; // Container for Q&A history this.followUpQuestionsElement = null; // Element for follow-up questions this.customQuestionContainer = null; // Container for custom question input/button this.customQuestionInput = null; this.customQuestionButton = null; this.attachImageButton = null; // Moved here this.refreshButton = null; // Add refresh button property // --- New: Image Upload Elements for Follow-up --- this.followUpImageContainer = null; // This will hold preview and remove button this.followUpImageInput = null; this.uploadedImageDataUrls = []; // Changed from single string to array // --- End New --- // --- Metadata Dropdown Elements --- this.metadataDropdown = null; this.metadataToggle = null; this.metadataArrow = null; this.metadataContent = null; // this.metadataElement is initialized later, it holds the actual metadata lines this.tooltipScrollableContentElement = null; // NEW: for the scrollable area this.status = 'pending'; // Initial status this.score = null; this.description = ''; this.reasoning = ''; this.metadata = null; // Add property to store metadata this.conversationHistory = []; // Array to store { question, answer } pairs this.questions = []; // Add property to store follow-up questions this.isPinned = false; this.isVisible = false; this.autoScroll = true; // Default to true for pending/streaming this.userInitiatedScroll = false; // Track user scroll interaction this.uploadedImageDataUrls = []; // Initialize this.qaConversationHistory = []; // Stores the full conversation history for API calls this.currentFollowUpSource = null; // Tracks if 'custom' or 'suggested' this._lastScrollPosition = 0; // Add property to track scroll position on mobile // Bind event handlers for proper cleanup this._boundHandlers = { handleMobileFocus: null, handleMobileTouchStart: null, handleAttachImageClick: null, handleKeyDown: null, handleFollowUpTouchStart: null, handleFollowUpTouchEnd: null, handleConversationReasoningToggle: null // Add this to store the bound handler }; try { this._createElements(tweetArticle); this._addEventListeners(); // Add to registry ScoreIndicatorRegistry.add(this.tweetId, this); } catch (error) { console.error(`[ScoreIndicator ${this.tweetId}] Failed initialization:`, error); // Attempt cleanup if elements were partially created this.destroy(); throw error; // Re-throw error after cleanup attempt } } // --- Private Methods --- /** * Creates the indicator and tooltip DOM elements. * @param {Element} initialTweetArticle - The article element to attach to initially. */ _createElements(initialTweetArticle) { // --- Indicator --- this.indicatorElement = document.createElement('div'); this.indicatorElement.className = 'score-indicator'; this.indicatorElement.dataset.tweetId = this.tweetId; // Link indicator to tweetId // Ensure parent is positioned only if not already relative or absolute const currentPosition = window.getComputedStyle(initialTweetArticle).position; if (currentPosition !== 'relative' && currentPosition !== 'absolute' && currentPosition !== 'fixed' && currentPosition !== 'sticky') { initialTweetArticle.style.position = 'relative'; } initialTweetArticle.appendChild(this.indicatorElement); // --- Tooltip --- this.tooltipElement = document.createElement('div'); this.tooltipElement.className = 'score-description'; this.tooltipElement.style.display = 'none'; this.tooltipElement.dataset.tweetId = this.tweetId; // Link tooltip to tweetId this.tooltipElement.dataset.autoScroll = this.autoScroll ? 'true' : 'false'; // Add touch-action to prevent scrolling on mobile when interacting with tooltip if (isMobileDevice()) { this.tooltipElement.style.touchAction = 'pan-x pan-y pinch-zoom'; } // --- Tooltip Controls --- this.tooltipControls = document.createElement('div'); this.tooltipControls.className = 'tooltip-controls'; // --- New Close Button --- this.tooltipCloseButton = document.createElement('button'); this.tooltipCloseButton.className = 'close-button tooltip-close-button'; // Reuse existing style + add specific class this.tooltipCloseButton.innerHTML = '×'; this.tooltipCloseButton.title = 'Close tooltip'; // --- End New Close Button --- this.pinButton = document.createElement('button'); this.pinButton.className = 'tooltip-pin-button'; this.pinButton.innerHTML = '📌'; this.pinButton.title = 'Pin tooltip (prevents auto-closing)'; this.copyButton = document.createElement('button'); this.copyButton.className = 'tooltip-copy-button'; this.copyButton.innerHTML = '📋'; this.copyButton.title = 'Copy content to clipboard'; this.refreshButton = document.createElement('button'); this.refreshButton.className = 'tooltip-refresh-button'; this.refreshButton.innerHTML = '🔄'; // Refresh icon this.refreshButton.title = 'Re-rate this tweet'; this.rateButton = document.createElement('button'); this.rateButton.className = 'tooltip-rate-button'; this.rateButton.innerHTML = '⭐'; // Star icon this.rateButton.title = 'Rate this tweet'; this.rateButton.style.display = 'none'; // Hidden by default, shown only in manual mode this.tooltipControls.appendChild(this.pinButton); this.tooltipControls.appendChild(this.copyButton); this.tooltipControls.appendChild(this.tooltipCloseButton); // Add the close button to controls this.tooltipControls.appendChild(this.refreshButton); this.tooltipControls.appendChild(this.rateButton); this.tooltipElement.appendChild(this.tooltipControls); // --- NEW: Scrollable Content Wrapper --- this.tooltipScrollableContentElement = document.createElement('div'); this.tooltipScrollableContentElement.className = 'tooltip-scrollable-content'; // Prevent default scrolling behavior on mobile for better control if (isMobileDevice()) { this.tooltipScrollableContentElement.style.webkitOverflowScrolling = 'touch'; this.tooltipScrollableContentElement.style.overscrollBehavior = 'contain'; } // --- Reasoning Dropdown --- this.reasoningDropdown = document.createElement('div'); this.reasoningDropdown.className = 'reasoning-dropdown'; this.reasoningDropdown.style.display = 'none'; // Hide initially this.reasoningToggle = document.createElement('div'); this.reasoningToggle.className = 'reasoning-toggle'; this.reasoningArrow = document.createElement('span'); this.reasoningArrow.className = 'reasoning-arrow'; this.reasoningArrow.textContent = '▶'; this.reasoningToggle.appendChild(this.reasoningArrow); this.reasoningToggle.appendChild(document.createTextNode(' Show Reasoning Trace')); this.reasoningContent = document.createElement('div'); this.reasoningContent.className = 'reasoning-content'; this.reasoningTextElement = document.createElement('p'); this.reasoningTextElement.className = 'reasoning-text'; this.reasoningContent.appendChild(this.reasoningTextElement); this.reasoningDropdown.appendChild(this.reasoningToggle); this.reasoningDropdown.appendChild(this.reasoningContent); this.tooltipScrollableContentElement.appendChild(this.reasoningDropdown); // MODIFIED: Append to scrollable // --- Description Area --- this.descriptionElement = document.createElement('div'); this.descriptionElement.className = 'description-text'; this.tooltipScrollableContentElement.appendChild(this.descriptionElement); // MODIFIED: Append to scrollable // --- Score Text Area (from description) --- this.scoreTextElement = document.createElement('div'); this.scoreTextElement.className = 'score-text-from-description'; this.scoreTextElement.style.display = 'none'; // Hide initially this.tooltipScrollableContentElement.appendChild(this.scoreTextElement); // MODIFIED: Append to scrollable // --- Follow-Up Questions Text Area (from description, hidden) --- this.followUpQuestionsTextElement = document.createElement('div'); this.followUpQuestionsTextElement.className = 'follow-up-questions-text-from-description'; this.followUpQuestionsTextElement.style.display = 'none'; // Always hidden this.tooltipScrollableContentElement.appendChild(this.followUpQuestionsTextElement); // MODIFIED: Append to scrollable // --- Conversation History Area --- this.conversationContainerElement = document.createElement('div'); this.conversationContainerElement.className = 'tooltip-conversation-history'; this.tooltipScrollableContentElement.appendChild(this.conversationContainerElement); // MODIFIED: Append to scrollable // --- Follow-Up Questions Area --- this.followUpQuestionsElement = document.createElement('div'); this.followUpQuestionsElement.className = 'tooltip-follow-up-questions'; this.followUpQuestionsElement.style.display = 'none'; // Hide initially this.tooltipScrollableContentElement.appendChild(this.followUpQuestionsElement); // MODIFIED: Append to scrollable // --- Custom Question Area --- this.customQuestionContainer = document.createElement('div'); this.customQuestionContainer.className = 'tooltip-custom-question-container'; this.customQuestionInput = document.createElement('textarea'); this.customQuestionInput.placeholder = 'Ask your own question...'; this.customQuestionInput.className = 'tooltip-custom-question-input'; this.customQuestionInput.rows = 1; // Start with a single row // Add event listener for dynamic height adjustment this.customQuestionInput.addEventListener('input', function() { // If empty, reset to single row if (this.value.trim() === '') { this.style.height = 'auto'; this.rows = 1; } else { this.style.height = 'auto'; // Reset height to recalculate this.style.height = (this.scrollHeight) + 'px'; // Set to scroll height } // Optionally, adjust rows attribute if preferred, but direct height is often smoother // const computedStyle = window.getComputedStyle(this); // const lineHeight = parseFloat(computedStyle.lineHeight); // const paddingTop = parseFloat(computedStyle.paddingTop); // const paddingBottom = parseFloat(computedStyle.paddingBottom); // const borderTop = parseFloat(computedStyle.borderTopWidth); // const borderBottom = parseFloat(computedStyle.borderBottomWidth); // const verticalPaddingAndBorder = paddingTop + paddingBottom + borderTop + borderBottom; // const lines = Math.floor((this.scrollHeight - verticalPaddingAndBorder) / lineHeight); // this.rows = Math.max(1, lines); }); // Check if model supports images to conditionally create image attach button const currentSelectedModel = browserGet('selectedModel', 'openai/gpt-4.1-nano'); // Provide a default or ensure it's always set const supportsImages = typeof modelSupportsImages === 'function' && modelSupportsImages(currentSelectedModel); if (supportsImages) { this.attachImageButton = document.createElement('button'); this.attachImageButton.textContent = '📎'; // Paperclip Icon this.attachImageButton.className = 'tooltip-attach-image-button'; this.attachImageButton.title = 'Attach image(s) or PDF(s)'; // Updated title this.followUpImageInput = document.createElement('input'); this.followUpImageInput.type = 'file'; this.followUpImageInput.accept = 'image/*,application/pdf'; // Accept both images and PDFs this.followUpImageInput.multiple = true; // Allow multiple files this.followUpImageInput.style.display = 'none'; // Hide the actual input } this.customQuestionButton = document.createElement('button'); this.customQuestionButton.textContent = 'Ask'; this.customQuestionButton.className = 'tooltip-custom-question-button'; this.customQuestionContainer.appendChild(this.customQuestionInput); if (this.attachImageButton) { this.customQuestionContainer.appendChild(this.attachImageButton); // The input needs to be in the DOM to be clickable, even if hidden. // It can be a direct child of the container or outside, as long as it's in the document. // For simplicity, let's add it here if attachImageButton exists. if (this.followUpImageInput) { this.customQuestionContainer.appendChild(this.followUpImageInput); } } this.customQuestionContainer.appendChild(this.customQuestionButton); // REMOVED: No longer appending to scrollable content // this.tooltipScrollableContentElement.appendChild(this.customQuestionContainer); // Pre-trigger focus on mobile to handle Safari's first-focus scroll behavior if (isMobileDevice() && this.customQuestionInput) { // Store current scroll position (should be 0 or minimal during creation) const initialScroll = this.tooltipScrollableContentElement?.scrollTop || 0; // Use a small delay to ensure DOM is fully ready setTimeout(() => { if (this.customQuestionInput && this.tooltipScrollableContentElement) { // Temporarily suppress any scroll behavior const preventScroll = (e) => { this.tooltipScrollableContentElement.scrollTop = initialScroll; e.preventDefault(); }; this.tooltipScrollableContentElement.addEventListener('scroll', preventScroll, { passive: false }); // Focus and immediately blur this.customQuestionInput.focus({ preventScroll: true }); this.customQuestionInput.blur(); // Clean up after a short delay setTimeout(() => { this.tooltipScrollableContentElement?.removeEventListener('scroll', preventScroll); // Ensure scroll is back to initial position if (this.tooltipScrollableContentElement) { this.tooltipScrollableContentElement.scrollTop = initialScroll; } }, 100); } }, 50); } // --- Image Preview and Remove Area (conditionally created) --- if (supportsImages) { this.followUpImageContainer = document.createElement('div'); this.followUpImageContainer.className = 'tooltip-follow-up-image-preview-container'; // New class for styling // this.followUpImageContainer.style.display = 'none'; // Display handled by content // MOVED: Image preview should also be fixed at bottom with the input // this.tooltipScrollableContentElement.appendChild(this.followUpImageContainer); } // --- End Image Preview and Remove Area --- // --- Metadata Dropdown Area --- this.metadataDropdown = document.createElement('div'); this.metadataDropdown.className = 'reasoning-dropdown'; // Reuse class this.metadataDropdown.style.display = 'none'; // Hide initially this.metadataToggle = document.createElement('div'); this.metadataToggle.className = 'reasoning-toggle'; // Reuse class this.metadataArrow = document.createElement('span'); this.metadataArrow.className = 'reasoning-arrow'; // Reuse class this.metadataArrow.textContent = '▶'; this.metadataToggle.appendChild(this.metadataArrow); this.metadataToggle.appendChild(document.createTextNode(' Show Metadata')); this.metadataContent = document.createElement('div'); this.metadataContent.className = 'reasoning-content'; // Reuse class // The existing metadataElement will now be the direct child holding the metadata text this.metadataElement = document.createElement('div'); // This was the original metadataElement this.metadataElement.className = 'tooltip-metadata'; // Keep its specific class for content styling this.metadataContent.appendChild(this.metadataElement); this.metadataDropdown.appendChild(this.metadataToggle); this.metadataDropdown.appendChild(this.metadataContent); // REMOVED: No longer appending to scrollable content // this.tooltipScrollableContentElement.appendChild(this.metadataDropdown); // --- End Metadata Dropdown Area --- // --- ADD Scrollable Content Wrapper to Tooltip Element --- this.tooltipElement.appendChild(this.tooltipScrollableContentElement); // --- Fixed Bottom Input Area --- // Add image preview container if it exists (above the input) if (this.followUpImageContainer) { this.tooltipElement.appendChild(this.followUpImageContainer); } // Add custom question container (fixed at bottom) this.tooltipElement.appendChild(this.customQuestionContainer); // Add metadata dropdown (below input area) this.tooltipElement.appendChild(this.metadataDropdown); // --- Scroll-to-Bottom Button --- this.scrollButton = document.createElement('div'); this.scrollButton.className = 'scroll-to-bottom-button'; this.scrollButton.innerHTML = '⬇ Scroll to bottom'; this.scrollButton.style.display = 'none'; // Hidden by default this.tooltipElement.appendChild(this.scrollButton); // --- Bottom Spacer (Now inside scrollable content) --- const bottomSpacer = document.createElement('div'); bottomSpacer.className = 'tooltip-bottom-spacer'; this.tooltipScrollableContentElement.appendChild(bottomSpacer); // MODIFIED: Append to scrollable // Append tooltip to body document.body.appendChild(this.tooltipElement); // Apply mobile styling if needed (assuming isMobileDevice is global) if (isMobileDevice()) { this.indicatorElement?.classList.add('mobile-indicator'); this.tooltipElement?.classList.add('mobile-tooltip'); // Add class for mobile tooltip styling } this._updateIndicatorUI(); // Set initial UI state this._updateTooltipUI(); // Set initial tooltip content (e.g., placeholders) // In constructor or _createElements, after creating this.conversationContainerElement: this.autoScrollConversation = true; if (this.conversationContainerElement) { this.conversationContainerElement.addEventListener('scroll', this._handleConversationScroll.bind(this)); } // Simulate initial taps on mobile to bypass first-tap issues if (isMobileDevice()) { this._initializeMobileInteractionFix(); } } /** * Initializes a fix for mobile first-tap scrolling issues by adding * a CSS class and tracking first interactions on elements. * @private */ _initializeMobileInteractionFix() { // Track if we've had the first interaction to prevent scroll jumps this._hasFirstInteraction = false; // Create a wrapper function to handle first tap logic const handleFirstTap = (e) => { if (!this._hasFirstInteraction) { // Mark that we've had first interaction this._hasFirstInteraction = true; // Store current scroll position before any potential jump const scrollTop = this.tooltipScrollableContentElement?.scrollTop || 0; // Use a small timeout to catch any scroll jumps that happen after the event setTimeout(() => { if (this.tooltipScrollableContentElement && this.tooltipScrollableContentElement.scrollTop !== scrollTop) { // Restore the scroll position if it jumped this.tooltipScrollableContentElement.scrollTop = scrollTop; } }, 0); // For input elements, we need to handle focus differently if (e.target === this.customQuestionInput && e.type === 'touchstart') { // Allow the default behavior but track scroll requestAnimationFrame(() => { if (this.tooltipScrollableContentElement) { this.tooltipScrollableContentElement.scrollTop = scrollTop; } }); } } }; // Add passive touchstart listeners to all interactive elements const interactiveElements = [ this.customQuestionInput, this.customQuestionButton, this.reasoningToggle, this.metadataToggle, this.pinButton, this.copyButton, this.tooltipCloseButton, this.refreshButton, this.rateButton, this.scrollButton ].filter(el => el); interactiveElements.forEach(element => { element.addEventListener('touchstart', handleFirstTap, { passive: true, capture: true }); }); // Special handling for the textarea to prevent scroll on focus if (this.customQuestionInput) { let scrollBeforeFocus = 0; this.customQuestionInput.addEventListener('touchstart', (e) => { scrollBeforeFocus = this.tooltipScrollableContentElement?.scrollTop || 0; }, { passive: true }); this.customQuestionInput.addEventListener('focus', (e) => { // On focus, restore scroll position if (scrollBeforeFocus > 0) { requestAnimationFrame(() => { if (this.tooltipScrollableContentElement) { this.tooltipScrollableContentElement.scrollTop = scrollBeforeFocus; } }); } }); } // Handle conversation container for dynamically created reasoning toggles if (this.conversationContainerElement) { // Use capturing phase to catch events before they bubble this.conversationContainerElement.addEventListener('touchstart', (e) => { const toggle = e.target.closest('.reasoning-toggle'); if (toggle) { handleFirstTap(e); } }, { passive: true, capture: true }); } // Also handle the main scrollable content to prevent unwanted scrolls if (this.tooltipScrollableContentElement) { let lastTouchY = 0; let scrollLocked = false; this.tooltipScrollableContentElement.addEventListener('touchstart', (e) => { lastTouchY = e.touches[0].clientY; scrollLocked = false; // If this is the first interaction and we're tapping an interactive element if (!this._hasFirstInteraction) { const interactiveTarget = e.target.closest('button, textarea, .reasoning-toggle'); if (interactiveTarget) { scrollLocked = true; const scrollTop = this.tooltipScrollableContentElement.scrollTop; // Prevent scroll for a brief moment requestAnimationFrame(() => { if (scrollLocked && this.tooltipScrollableContentElement) { this.tooltipScrollableContentElement.scrollTop = scrollTop; } }); // Unlock after a short delay setTimeout(() => { scrollLocked = false; }, 100); } } }, { passive: true }); } } /** * Simulates initial tap events on mobile interactive elements to bypass * the first-tap scrolling issue that occurs on some mobile browsers. * @private */ _simulateInitialMobileTaps() { // Use setTimeout to ensure DOM is fully ready setTimeout(() => { // List of elements that need the initial tap simulation const elementsToTap = [ this.customQuestionInput, this.customQuestionButton, this.reasoningToggle, this.metadataToggle ].filter(el => el); // Filter out null/undefined elements elementsToTap.forEach(element => { try { // Create and dispatch a touchstart event const touchEvent = new TouchEvent('touchstart', { bubbles: true, cancelable: true, view: window, touches: [new Touch({ identifier: Date.now(), target: element, clientX: 0, clientY: 0, screenX: 0, screenY: 0, pageX: 0, pageY: 0, })] }); element.dispatchEvent(touchEvent); // Immediately dispatch touchend const touchEndEvent = new TouchEvent('touchend', { bubbles: true, cancelable: true, view: window, changedTouches: [new Touch({ identifier: Date.now(), target: element, clientX: 0, clientY: 0, screenX: 0, screenY: 0, pageX: 0, pageY: 0, })] }); element.dispatchEvent(touchEndEvent); } catch (e) { // Fallback for browsers that don't support Touch constructor try { const event = document.createEvent('TouchEvent'); event.initTouchEvent('touchstart', true, true); element.dispatchEvent(event); } catch (fallbackError) { // If touch events aren't supported, try a click element.click(); // Immediately blur to prevent any focus issues if (element.blur) { element.blur(); } } } }); }, 100); // Small delay to ensure everything is ready } /** Adds necessary event listeners to the indicator and tooltip. */ _addEventListeners() { if (!this.indicatorElement || !this.tooltipElement) return; // Indicator Events this.indicatorElement.addEventListener('mouseenter', this._handleMouseEnter.bind(this)); this.indicatorElement.addEventListener('mouseleave', this._handleMouseLeave.bind(this)); this.indicatorElement.addEventListener('click', this._handleIndicatorClick.bind(this)); // Tooltip Events this.tooltipElement.addEventListener('mouseenter', this._handleTooltipMouseEnter.bind(this)); this.tooltipElement.addEventListener('mouseleave', this._handleTooltipMouseLeave.bind(this)); // MODIFIED: Scroll event listener should be on the new scrollable element this.tooltipScrollableContentElement?.addEventListener('scroll', this._handleTooltipScroll.bind(this)); // Tooltip Controls Events this.pinButton?.addEventListener('click', this._handlePinClick.bind(this)); this.copyButton?.addEventListener('click', this._handleCopyClick.bind(this)); this.tooltipCloseButton?.addEventListener('click', this._handleCloseClick.bind(this)); this.reasoningToggle?.addEventListener('click', this._handleReasoningToggleClick.bind(this)); this.scrollButton?.addEventListener('click', this._handleScrollButtonClick.bind(this)); this.refreshButton?.addEventListener('click', this._handleRefreshClick.bind(this)); this.rateButton?.addEventListener('click', this._handleRateClick.bind(this)); // Follow-up Questions (using delegation on the container) this.followUpQuestionsElement?.addEventListener('click', this._handleFollowUpQuestionClick.bind(this)); // Add touch event handling for mobile to prevent scrolling if (isMobileDevice() && this.followUpQuestionsElement) { this._boundHandlers.handleFollowUpTouchStart = (e) => { const button = e.target.closest('.follow-up-question-button'); if (button) { e.preventDefault(); // Prevent any default touch behavior // Store the touch position to detect if it's a tap button.dataset.touchStartX = e.touches[0].clientX; button.dataset.touchStartY = e.touches[0].clientY; } }; this._boundHandlers.handleFollowUpTouchEnd = (e) => { const button = e.target.closest('.follow-up-question-button'); if (button && button.dataset.touchStartX) { e.preventDefault(); // Prevent any default behavior // Check if it was a tap (not a swipe) const touchEndX = e.changedTouches[0].clientX; const touchEndY = e.changedTouches[0].clientY; const deltaX = Math.abs(touchEndX - parseFloat(button.dataset.touchStartX)); const deltaY = Math.abs(touchEndY - parseFloat(button.dataset.touchStartY)); // If movement is minimal, treat it as a tap if (deltaX < 10 && deltaY < 10) { // Trigger the click handler directly this._handleFollowUpQuestionClick({ target: button, preventDefault: () => {}, stopPropagation: () => {} }); } // Clean up delete button.dataset.touchStartX; delete button.dataset.touchStartY; } }; this.followUpQuestionsElement.addEventListener('touchstart', this._boundHandlers.handleFollowUpTouchStart, { passive: false }); this.followUpQuestionsElement.addEventListener('touchend', this._boundHandlers.handleFollowUpTouchEnd, { passive: false }); } // Custom Question Button this.customQuestionButton?.addEventListener('click', this._handleCustomQuestionClick.bind(this)); // Allow submitting custom question with Enter key // Shift + Enter should insert a newline instead of submitting this._boundHandlers.handleKeyDown = (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); // Prevent newline this._handleCustomQuestionClick(event); // Pass event parameter } }; this.customQuestionInput?.addEventListener('keydown', this._boundHandlers.handleKeyDown); // Add focus handler for mobile to prevent scrolling if (isMobileDevice() && this.customQuestionInput) { // With the input outside scrollable area, we only need basic tracking this._boundHandlers.handleMobileFocus = (event) => { // No longer need to prevent scrolling since input is outside scrollable content // Just track focus state if needed for other purposes }; this._boundHandlers.handleMobileTouchStart = (event) => { // Store any state if needed this._lastScrollPosition = this.tooltipScrollableContentElement?.scrollTop || 0; }; // Only add listeners if we actually need them for something // this.customQuestionInput.addEventListener('focus', this._boundHandlers.handleMobileFocus); // this.customQuestionInput.addEventListener('touchstart', this._boundHandlers.handleMobileTouchStart, { passive: true }); } // Metadata Toggle this.metadataToggle?.addEventListener('click', this._handleMetadataToggleClick.bind(this)); // --- New: Event Listeners for Image Upload (conditional) --- if (this.attachImageButton && this.followUpImageInput) { this._boundHandlers.handleAttachImageClick = (event) => { event.preventDefault(); event.stopPropagation(); this.followUpImageInput.click(); }; this.attachImageButton.addEventListener('click', this._boundHandlers.handleAttachImageClick); this.followUpImageInput.addEventListener('change', this._handleFollowUpImageSelect.bind(this)); } // No global remove button listener now // if (this.followUpRemoveImageButton) { // this.followUpRemoveImageButton.addEventListener('click', this._handleRemoveFollowUpImage.bind(this)); // } // --- End New --- } /** Updates the visual appearance of the indicator (icon/text, class). */ _updateIndicatorUI() { if (!this.indicatorElement) return; // Clear previous status classes const classList = this.indicatorElement.classList; classList.remove( 'pending-rating', 'rated-rating', 'error-rating', 'cached-rating', 'blacklisted-rating', 'streaming-rating', 'manual-rating', 'blacklisted-author-indicator' // Ensure to remove this as well before re-evaluating ); let indicatorText = ''; let indicatorClass = ''; if (this.isAuthorBlacklisted) { // Author blacklist takes visual precedence indicatorClass = 'blacklisted-author-indicator'; // Purple class indicatorText = (this.score !== null && this.score !== undefined) ? String(this.score) : '?'; } else { // Not a blacklisted author, proceed with normal status switch (this.status) { case 'pending': indicatorClass = 'pending-rating'; indicatorText = '⏳'; break; case 'streaming': indicatorClass = 'streaming-rating'; indicatorText = (this.score !== null && this.score !== undefined) ? String(this.score) : '🔄'; break; case 'error': indicatorClass = 'error-rating'; indicatorText = '⚠️'; break; case 'cached': indicatorClass = 'cached-rating'; indicatorText = String(this.score); break; case 'blacklisted': // This is for TWEET status being blacklisted (amber color) indicatorClass = 'blacklisted-rating'; indicatorText = String(this.score); break; case 'manual': indicatorClass = 'manual-rating'; indicatorText = '💭'; break; case 'rated': default: indicatorClass = 'rated-rating'; indicatorText = String(this.score); break; } } if (indicatorClass) { classList.add(indicatorClass); } this.indicatorElement.textContent = indicatorText; } /** Updates the content and potentially scroll position of the tooltip. */ _updateTooltipUI() { // Ensure required elements exist if (!this.tooltipElement || !this.tooltipScrollableContentElement || !this.descriptionElement || !this.scoreTextElement || !this.followUpQuestionsTextElement || !this.reasoningTextElement || !this.reasoningDropdown || !this.conversationContainerElement || !this.followUpQuestionsElement || !this.metadataElement || !this.metadataDropdown) { return; } // Store current scroll position and whether we were at bottom before update const wasNearBottom = this.tooltipScrollableContentElement.scrollHeight - this.tooltipScrollableContentElement.scrollTop - this.tooltipScrollableContentElement.clientHeight < (isMobileDevice() ? 40 : 55); const previousScrollTop = this.tooltipScrollableContentElement.scrollTop; const previousScrollHeight = this.tooltipScrollableContentElement.scrollHeight; // --- Parse the description into parts --- const fullDescription = this.description || ""; const analysisMatch = fullDescription.match(/<ANALYSIS>([^<]+)<\/ANALYSIS>/); const scoreMatch = fullDescription.match(/<SCORE>([^<]+)<\/SCORE>/); const questionsMatch = fullDescription.match(/<FOLLOW_UP_QUESTIONS>([^<]+)<\/FOLLOW_UP_QUESTIONS>/); let analysisContent = ""; let scoreContent = ""; let questionsContent = ""; if (analysisMatch && analysisMatch[1] !== undefined) { analysisContent = analysisMatch[1].trim(); } else if (!scoreMatch && !questionsMatch) { // Fallback: If no tags found, assume entire description is analysis analysisContent = fullDescription; } else { // If other tags exist but no analysis tag, leave analysis empty analysisContent = "*Waiting for analysis...*"; // Or some placeholder } if (scoreMatch && scoreMatch[1] !== undefined) { scoreContent = scoreMatch[1].trim(); } if (questionsMatch && questionsMatch[1] !== undefined) { questionsContent = questionsMatch[1].trim(); } // --- End Parsing --- // Use a flag to track if any significant content affecting layout changed let contentChanged = false; // Update Analysis display (using descriptionElement) const formattedAnalysis = formatTooltipDescription(analysisContent).description; // Pass only analysis part if (this.descriptionElement.innerHTML !== formattedAnalysis) { this.descriptionElement.innerHTML = formattedAnalysis; contentChanged = true; } // Update Score display (using scoreTextElement) if (scoreContent) { // Apply score highlighting specifically here const formattedScoreText = scoreContent .replace(/</g, '<').replace(/>/g, '>') // Basic escaping .replace(/SCORE_(\d+)/g, '<span class="score-highlight">SCORE: $1</span>') // Apply highlighting .replace(/\n/g, '<br>'); // Line breaks if (this.scoreTextElement.innerHTML !== formattedScoreText) { this.scoreTextElement.innerHTML = formattedScoreText; contentChanged = true; } this.scoreTextElement.style.display = 'block'; } else { if (this.scoreTextElement.style.display !== 'none') { this.scoreTextElement.style.display = 'none'; this.scoreTextElement.innerHTML = ''; contentChanged = true; // Hiding/showing counts as change } } // Update Follow-up Questions display (using followUpQuestionsTextElement - always hidden) if (questionsContent) { const formattedQuestionsText = questionsContent.replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>'); if (this.followUpQuestionsTextElement.innerHTML !== formattedQuestionsText) { this.followUpQuestionsTextElement.innerHTML = formattedQuestionsText; // No contentChanged = true needed as it's always hidden } } else { if (this.followUpQuestionsTextElement.innerHTML !== '') { this.followUpQuestionsTextElement.innerHTML = ''; } } this.followUpQuestionsTextElement.style.display = 'none'; // Ensure it's always hidden // --- Update Reasoning Display --- const formattedReasoning = formatTooltipDescription("", this.reasoning).reasoning; if (this.reasoningTextElement.innerHTML !== formattedReasoning) { this.reasoningTextElement.innerHTML = formattedReasoning; contentChanged = true; } const showReasoning = !!formattedReasoning; if ((this.reasoningDropdown.style.display === 'none') === showReasoning) { this.reasoningDropdown.style.display = showReasoning ? 'block' : 'none'; contentChanged = true; // Hiding/showing counts as change } // --- Update Conversation History Display --- const renderedHistory = this._renderConversationHistory(); if (this.conversationContainerElement.innerHTML !== renderedHistory) { this.conversationContainerElement.innerHTML = renderedHistory; this.conversationContainerElement.style.display = this.conversationHistory.length > 0 ? 'block' : 'none'; contentChanged = true; } // --- Update Follow-Up Questions Buttons Display --- let questionsButtonsChanged = false; // Simple check: compare number of buttons to number of questions if (this.followUpQuestionsElement.children.length !== (this.questions?.length || 0)) { questionsButtonsChanged = true; } else { // More thorough check: compare text of each question this.questions?.forEach((q, i) => { const button = this.followUpQuestionsElement.children[i]; if (!button || button.dataset.questionText !== q) { questionsButtonsChanged = true; } }); } if (questionsButtonsChanged) { this.followUpQuestionsElement.innerHTML = ''; // Clear previous questions if (this.questions && this.questions.length > 0) { this.questions.forEach((question, index) => { const questionButton = document.createElement('button'); questionButton.className = 'follow-up-question-button'; questionButton.textContent = `🤔 ${question}`; questionButton.dataset.questionIndex = index; questionButton.dataset.questionText = question; // Store text for handler // Prevent focus scrolling on mobile if (isMobileDevice()) { // Track if this specific button has been tapped before let hasBeenTapped = false; questionButton.addEventListener('touchstart', (e) => { if (!hasBeenTapped) { hasBeenTapped = true; const scrollTop = this.tooltipScrollableContentElement?.scrollTop || 0; requestAnimationFrame(() => { if (this.tooltipScrollableContentElement) { this.tooltipScrollableContentElement.scrollTop = scrollTop; } }); } }, { passive: true }); questionButton.addEventListener('focus', (e) => { // Blur immediately to prevent focus styling and scrolling e.target.blur(); }, { passive: true }); } this.followUpQuestionsElement.appendChild(questionButton); }); this.followUpQuestionsElement.style.display = 'block'; } else { this.followUpQuestionsElement.style.display = 'none'; } contentChanged = true; } // --- Update Metadata Display (now in a dropdown) --- let metadataHTML = ''; let showMetadataDropdown = false; // Renamed from showMetadata for clarity const hasFullMetadata = this.metadata && Object.keys(this.metadata).length > 1 && this.metadata.model; const hasOnlyGenId = this.metadata && this.metadata.generationId && Object.keys(this.metadata).length === 1; if (hasFullMetadata) { // No <hr> here, reasoning-dropdown class provides border-top styling if needed if (this.metadata.providerName && this.metadata.providerName !== 'N/A') { metadataHTML += `<div class="metadata-line">Provider: ${this.metadata.providerName}</div>`; } metadataHTML += `<div class="metadata-line">Model: ${this.metadata.model}</div>`; metadataHTML += `<div class="metadata-line">Tokens: prompt: ${this.metadata.promptTokens} / completion: ${this.metadata.completionTokens}</div>`; if (this.metadata.reasoningTokens > 0) { metadataHTML += `<div class="metadata-line">Reasoning Tokens: ${this.metadata.reasoningTokens}</div>`; } metadataHTML += `<div class="metadata-line">Latency: ${this.metadata.latency}</div>`; if (this.metadata.mediaInputs > 0) { metadataHTML += `<div class="metadata-line">Media: ${this.metadata.mediaInputs}</div>`; } metadataHTML += `<div class="metadata-line">Price: ${this.metadata.price}</div>`; showMetadataDropdown = true; } else if (hasOnlyGenId) { metadataHTML += `<div class="metadata-line">Generation ID: ${this.metadata.generationId} (fetching details...)</div>`; showMetadataDropdown = true; } if (this.metadataElement.innerHTML !== metadataHTML) { // this.metadataElement is the inner content holder this.metadataElement.innerHTML = metadataHTML; contentChanged = true; } // Show/hide the entire dropdown based on whether there's metadata if (this.metadataDropdown) { const currentDisplay = this.metadataDropdown.style.display; const newDisplay = showMetadataDropdown ? 'block' : 'none'; if (currentDisplay !== newDisplay) { this.metadataDropdown.style.display = newDisplay; contentChanged = true; } } // --- End Metadata Display Update --- // Add/remove streaming class const isStreaming = this.status === 'streaming'; if (this.tooltipElement.classList.contains('streaming-tooltip') !== isStreaming) { this.tooltipElement.classList.toggle('streaming-tooltip', isStreaming); contentChanged = true; // Class change might affect layout/appearance } // Show/hide rate button based on status if (this.rateButton) { const showRateButton = this.status === 'manual'; const currentDisplay = this.rateButton.style.display; const newDisplay = showRateButton ? 'inline-block' : 'none'; if (currentDisplay !== newDisplay) { this.rateButton.style.display = newDisplay; contentChanged = true; } } // Handle scrolling after content update if (contentChanged) { requestAnimationFrame(() => { // Check conditions again inside RAF, as state might have changed // (e.g. visibility, or if tooltipScrollableContentElement was somehow removed) if (this.tooltipScrollableContentElement && this.isVisible) { if (this.autoScroll) { // Use the current this.autoScroll state this._performAutoScroll(); } else { // If autoScroll is false, it means user scrolled away or streaming ended // and wasn't at the very bottom. Restore their previous position // to prevent the browser from defaulting to scroll_top=0 after large DOM changes. this.tooltipScrollableContentElement.scrollTop = previousScrollTop; } } this._updateScrollButtonVisibility(); // Always update button visibility }); } else { // Ensure scroll button visibility is correct even if content didn't change significantly this._updateScrollButtonVisibility(); } } /** Renders the conversation history into HTML string */ _renderConversationHistory() { if (!this.conversationHistory || this.conversationHistory.length === 0) { return ''; } // Store current expanded states before re-rendering const expandedStates = new Map(); if (this.conversationContainerElement) { this.conversationContainerElement.querySelectorAll('.conversation-reasoning').forEach((dropdown, index) => { expandedStates.set(index, dropdown.classList.contains('expanded')); }); } let historyHtml = ''; this.conversationHistory.forEach((turn, index) => { const formattedQuestion = turn.question .replace(/</g, '<').replace(/>/g, '>'); // Basic escaping let uploadedImageHtml = ''; if (turn.uploadedImages && turn.uploadedImages.length > 0) { uploadedImageHtml = ` <div class="conversation-image-container"> ${turn.uploadedImages.map(url => { if (url.startsWith('data:application/pdf')) { // Display PDF icon for PDFs return ` <div class="conversation-uploaded-pdf" style="display: inline-block; text-align: center; margin: 4px;"> <span style="font-size: 48px;">📄</span> <div style="font-size: 12px;">PDF Document</div> </div> `; } else { // Display image preview return `<img src="${url}" alt="User uploaded image" class="conversation-uploaded-image">`; } }).join('')} </div> `; } let formattedAnswer; if (turn.answer === 'pending') { formattedAnswer = '<em class="pending-answer">Answering...</em>'; } else { // Apply formatting similar to the main description/reasoning formattedAnswer = turn.answer .replace(/```([\s\S]*?)```/g, (m, code) => `<pre><code>${code.replace(/</g,'<').replace(/>/g,'>')}</code></pre>`) .replace(/</g, '<').replace(/>/g, '>') // Escape potential raw HTML first // Format markdown links: [text](url) -> <a href="url">text</a> .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="ai-generated-link">$1</a>') // Added class .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*(.*?)\*/g, '<em>$1</em>') .replace(/`([^`]+)`/g, '<code>$1</code>') // Process Markdown Tables before line breaks .replace(/^\|(.+)\|\r?\n\|([\s\|\-:]+)\|\r?\n(\|(?:.+)\|\r?\n?)+/gm, (match) => { const rows = match.trim().split('\n'); const headerRow = rows[0]; // const separatorRow = rows[1]; // Not strictly needed here for formatting const bodyRows = rows.slice(2); let html = '<table class="markdown-table">'; html += '<thead><tr>'; headerRow.slice(1, -1).split('|').forEach(cell => { html += `<th>${cell.trim()}</th>`; }); html += '</tr></thead>'; html += '<tbody>'; bodyRows.forEach(rowStr => { if (!rowStr.trim()) return; html += '<tr>'; rowStr.slice(1, -1).split('|').forEach(cell => { html += `<td>${cell.trim()}</td>`; }); html += '</tr>'; }); html += '</tbody></table>'; return html; }) .replace(/\n/g, '<br>'); } // Add a separator before each Q&A pair except the first one if (index > 0) { historyHtml += '<hr class="conversation-separator">'; } // --- Add Reasoning Dropdown (if present) --- let reasoningHtml = ''; if (turn.reasoning && turn.reasoning.trim() !== '' && turn.answer !== 'pending') { const formattedReasoning = formatTooltipDescription("", turn.reasoning).reasoning; // Check if this dropdown was expanded const wasExpanded = expandedStates.get(index); const expandedClass = wasExpanded ? ' expanded' : ''; const arrowChar = wasExpanded ? '▼' : '▶'; const contentStyle = wasExpanded ? 'style="max-height: 200px; padding: 8px;"' : 'style="max-height: 0; padding: 0 8px;"'; reasoningHtml = ` <div class="reasoning-dropdown conversation-reasoning${expandedClass}" data-index="${index}"> <div class="reasoning-toggle" role="button" tabindex="0" aria-expanded="${wasExpanded ? 'true' : 'false'}"> <span class="reasoning-arrow">${arrowChar}</span> Show Reasoning Trace </div> <div class="reasoning-content" ${contentStyle}> <p class="reasoning-text">${formattedReasoning}</p> </div> </div> `; } historyHtml += ` <div class="conversation-turn"> <div class="conversation-question"><strong>You:</strong> ${formattedQuestion}</div> ${uploadedImageHtml} ${reasoningHtml} <div class="conversation-answer"><strong>AI:</strong> ${formattedAnswer}</div> </div> `; }); // Update the conversation container with the new HTML if (this.conversationContainerElement) { this.conversationContainerElement.innerHTML = historyHtml; // Attach event listeners after updating the HTML this._attachConversationReasoningListeners(); } return historyHtml; } /** * Attaches event listeners to reasoning toggles within the conversation history. * Uses event delegation. */ _attachConversationReasoningListeners() { if (!this.conversationContainerElement) return; // Remove any existing listener using the stored reference if (this._boundHandlers.handleConversationReasoningToggle) { this.conversationContainerElement.removeEventListener('click', this._boundHandlers.handleConversationReasoningToggle); } // Create and store the new bound handler this._boundHandlers.handleConversationReasoningToggle = (e) => { const toggleButton = e.target.closest('.conversation-reasoning .reasoning-toggle'); if (!toggleButton) return; // Only prevent default on non-touch events or if we're sure it's a tap if (e.type === 'click' && !e.isTrusted) { // This might be a synthetic click from touch, let it through return; } const dropdown = toggleButton.closest('.reasoning-dropdown'); const content = dropdown?.querySelector('.reasoning-content'); const arrow = dropdown?.querySelector('.reasoning-arrow'); if (!dropdown || !content || !arrow) return; // Store scroll position before toggle const scrollTop = this.tooltipScrollableContentElement?.scrollTop || 0; const isExpanded = dropdown.classList.toggle('expanded'); arrow.textContent = isExpanded ? '▼' : '▶'; toggleButton.setAttribute('aria-expanded', isExpanded); content.style.maxHeight = isExpanded ? '200px' : '0'; content.style.padding = isExpanded ? '8px' : '0 8px'; // Restore scroll position if on mobile if (isMobileDevice() && this.tooltipScrollableContentElement) { requestAnimationFrame(() => { if (this.tooltipScrollableContentElement) { this.tooltipScrollableContentElement.scrollTop = scrollTop; } }); } }; // Add the new listener using the stored reference this.conversationContainerElement.addEventListener('click', this._boundHandlers.handleConversationReasoningToggle); } _performAutoScroll() { if (!this.tooltipScrollableContentElement || !this.autoScroll || !this.isVisible) return; // MODIFIED // Use double RAF to ensure DOM has updated dimensions requestAnimationFrame(() => { requestAnimationFrame(() => { // Check conditions again inside RAF, as state might have changed if (this.tooltipScrollableContentElement && this.autoScroll && this.isVisible) { // MODIFIED const targetScroll = this.tooltipScrollableContentElement.scrollHeight; // MODIFIED this.tooltipScrollableContentElement.scrollTo({ // MODIFIED top: targetScroll, behavior: 'instant' // Ensure 'instant' }); // Double-check after a short delay -- REMOVED // setTimeout(() => { // if (this.tooltipElement && this.autoScroll && this.isVisible) { // // Check if we are actually at the bottom, if not, scroll again // const isNearBottom = this.tooltipElement.scrollHeight - this.tooltipElement.scrollTop - this.tooltipElement.clientHeight < 5; // Use a small tolerance // if (!isNearBottom) { // this.tooltipElement.scrollTop = this.tooltipElement.scrollHeight; // } // } // }, 50); } }); }); } /** Calculates and sets the tooltip's position. */ _setPosition() { if (!this.isVisible || !this.indicatorElement || !this.tooltipElement) return; const indicatorRect = this.indicatorElement.getBoundingClientRect(); const tooltip = this.tooltipElement; const margin = 10; const isMobile = isMobileDevice(); // Assume global function const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; const safeAreaHeight = viewportHeight - margin; const safeAreaWidth = viewportWidth - margin; // Reset styles that affect measurement tooltip.style.maxHeight = ''; tooltip.style.overflowY = ''; tooltip.style.visibility = 'hidden'; // Keep hidden during measurement tooltip.style.display = 'block'; // Ensure it's displayed for measurement // Use getComputedStyle for more reliable dimensions const computedStyle = window.getComputedStyle(tooltip); const tooltipWidth = parseFloat(computedStyle.width); let tooltipHeight = parseFloat(computedStyle.height); let left, top; let finalMaxHeight = ''; let finalOverflowY = ''; if (isMobile) { // Center horizontally, clamp to viewport left = Math.max(margin, (viewportWidth - tooltipWidth) / 2); if (left + tooltipWidth > safeAreaWidth) { left = safeAreaWidth - tooltipWidth; // Adjust if too wide } // Limit height to 80% of viewport const maxTooltipHeight = viewportHeight * 0.8; if (tooltipHeight > maxTooltipHeight) { finalMaxHeight = `${maxTooltipHeight}px`; finalOverflowY = 'scroll'; tooltipHeight = maxTooltipHeight; // Use constrained height for positioning } // Center vertically, clamp to viewport top = Math.max(margin, (viewportHeight - tooltipHeight) / 2); if (top + tooltipHeight > safeAreaHeight) { top = safeAreaHeight - tooltipHeight; } } else { // Desktop Positioning // Default: Right of indicator left = indicatorRect.right + margin; top = indicatorRect.top + (indicatorRect.height / 2) - (tooltipHeight / 2); // Check right overflow if (left + tooltipWidth > safeAreaWidth) { // Try: Left of indicator left = indicatorRect.left - tooltipWidth - margin; // Check left overflow if (left < margin) { // Try: Centered horizontally left = Math.max(margin, (viewportWidth - tooltipWidth) / 2); // Try: Below indicator if (indicatorRect.bottom + tooltipHeight + margin <= safeAreaHeight) { top = indicatorRect.bottom + margin; } // Try: Above indicator else if (indicatorRect.top - tooltipHeight - margin >= margin) { top = indicatorRect.top - tooltipHeight - margin; } // Last resort: Fit vertically with scrolling else { top = margin; finalMaxHeight = `${safeAreaHeight - margin}px`; // Use remaining height finalOverflowY = 'scroll'; tooltipHeight = safeAreaHeight - margin; // Use constrained height } } } // Final vertical check & adjustment if (top < margin) { top = margin; } if (top + tooltipHeight > safeAreaHeight) { // If tooltip is taller than viewport space, enable scrolling if (tooltipHeight > safeAreaHeight - margin) { top = margin; finalMaxHeight = `${safeAreaHeight - margin}px`; finalOverflowY = 'scroll'; } else { // Otherwise, just move it up top = safeAreaHeight - tooltipHeight; } } } // Apply calculated styles tooltip.style.position = 'fixed'; tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; tooltip.style.zIndex = '99999999'; // Ensure high z-index tooltip.style.maxHeight = finalMaxHeight; // This is still valid for the outer container // tooltip.style.overflowY = finalOverflowY; // REMOVED: Outer container should not get JS-set overflowY tooltip.style.overflowY = ''; // Explicitly clear any JS-set overflowY on outer container // Force scrollbars on WebKit if needed (This might be irrelevant now for the outer container) // if (finalOverflowY === 'scroll') { // tooltip.style.webkitOverflowScrolling = 'touch'; // } tooltip.style.display = 'flex'; // RESTORED: Ensure flex display mode is set before making visible // Make visible AFTER positioning tooltip.style.visibility = 'visible'; } _updateScrollButtonVisibility() { if (!this.tooltipScrollableContentElement || !this.scrollButton) return; // MODIFIED const isStreaming = this.status === 'streaming'; if (!isStreaming) { this.scrollButton.style.display = 'none'; return; } // Check if scrolled near the bottom const isNearBottom = this.tooltipScrollableContentElement.scrollHeight - this.tooltipScrollableContentElement.scrollTop - this.tooltipScrollableContentElement.clientHeight < (isMobileDevice() ? 40 : 55); // MODIFIED this.scrollButton.style.display = isNearBottom ? 'none' : 'block'; } // --- Event Handlers --- _handleMouseEnter(event) { if (isMobileDevice()) return; this.show(); } _handleMouseLeave(event) { if (isMobileDevice()) return; // Use timeout to allow moving cursor to the tooltip itself setTimeout(() => { // Check if the tooltip itself or the indicator is still hovered if (this.tooltipElement && !this.tooltipElement.matches(':hover') && this.indicatorElement && !this.indicatorElement.matches(':hover')) { this.hide(); } }, 100); } _handleIndicatorClick(event) { event.stopPropagation(); event.preventDefault(); this.toggle(); } _handleTooltipMouseEnter() { // Keep tooltip visible when mouse enters it (necessary if mouseleave timeout is short) if (!this.isPinned) { this.show(); // Re-affirm visibility } } _handleTooltipMouseLeave() { // If not pinned, hide the tooltip when mouse leaves it setTimeout(() => { if (!this.isPinned && !(this.indicatorElement.matches(':hover') || this.tooltipElement.matches(':hover'))) { this.hide(); } }, 100); } _handleTooltipScroll() { if (!this.tooltipScrollableContentElement) return; // MODIFIED // Check if we're near the bottom BEFORE potentially disabling autoScroll const isNearBottom = this.tooltipScrollableContentElement.scrollHeight - this.tooltipScrollableContentElement.scrollTop - this.tooltipScrollableContentElement.clientHeight < (isMobileDevice() ? 40 : 55); // MODIFIED // If user is scrolling up or away from bottom if (!isNearBottom) { if (this.autoScroll) { this.autoScroll = false; this.tooltipElement.dataset.autoScroll = 'false'; // Keep this on main tooltip for now, or move if makes sense this.userInitiatedScroll = true; } } else { // Only re-enable auto-scroll if user explicitly scrolled to bottom if (this.userInitiatedScroll) { this.autoScroll = true; this.tooltipElement.dataset.autoScroll = 'true'; // Keep this on main tooltip this.userInitiatedScroll = false; } } this._updateScrollButtonVisibility(); } _handlePinClick(e) { if (e) { e.stopPropagation(); } if (this.isPinned) { this.unpin(); } else { this.pin(); } } _handleCopyClick(e) { if (e) { e.stopPropagation(); } if (!this.descriptionElement || !this.reasoningTextElement || !this.copyButton) return; let textToCopy = this.descriptionElement.textContent || ''; // Use textContent to avoid HTML const reasoningContent = this.reasoningTextElement.textContent || ''; if (reasoningContent) { textToCopy += '\n\nReasoning:\n' + reasoningContent; } navigator.clipboard.writeText(textToCopy).then(() => { const originalText = this.copyButton.innerHTML; this.copyButton.innerHTML = '✓'; this.copyButton.disabled = true; setTimeout(() => { this.copyButton.innerHTML = originalText; this.copyButton.disabled = false; }, 1500); }).catch(err => { console.error('[ScoreIndicator] Failed to copy text: ', err); // Optionally provide user feedback here }); } _handleReasoningToggleClick(e) { if (e) { e.stopPropagation(); } if (!this.reasoningDropdown || !this.reasoningContent || !this.reasoningArrow) return; // Store scroll position before toggle const scrollTop = this.tooltipScrollableContentElement?.scrollTop || 0; const isExpanded = this.reasoningDropdown.classList.toggle('expanded'); this.reasoningArrow.textContent = isExpanded ? '▼' : '▶'; if (isExpanded) { this.reasoningContent.style.maxHeight = '300px'; // Allow height transition this.reasoningContent.style.padding = '10px'; } else { this.reasoningContent.style.maxHeight = '0'; this.reasoningContent.style.padding = '0 10px'; // Keep horizontal padding } // Restore scroll position on mobile to prevent jumping if (isMobileDevice() && this.tooltipScrollableContentElement) { requestAnimationFrame(() => { if (this.tooltipScrollableContentElement) { this.tooltipScrollableContentElement.scrollTop = scrollTop; } }); } } _handleScrollButtonClick(e) { if (e) { e.stopPropagation(); } if (!this.tooltipScrollableContentElement) return; // MODIFIED this.autoScroll = true; this.tooltipElement.dataset.autoScroll = 'true'; // Keep this on main tooltip this._performAutoScroll(); this._updateScrollButtonVisibility(); // Should hide the button now } _handleFollowUpQuestionClick(event) { // Prevent default to avoid mobile scrolling issues if (event && typeof event.preventDefault === 'function') { event.preventDefault(); } // If called from _handleCustomQuestionClick, event.target will be our mockButton // Otherwise, it's a real DOM event and we need to find the button. const isMockEvent = event.target && event.target.dataset && event.target.dataset.questionText && typeof event.target.closest !== 'function'; const button = isMockEvent ? event.target : event.target.closest('.follow-up-question-button'); if (!button) return; // Should not happen if called from custom handler with mockButton event.stopPropagation(); // Prevent tooltip hide if it's a real event const questionText = button.dataset.questionText; const apiKey = browserGet('openrouter-api-key', ''); // Set the source of the follow-up this.currentFollowUpSource = isMockEvent ? 'custom' : 'suggested'; // Add immediate feedback - only if it's a real button if (!isMockEvent) { button.disabled = true; button.textContent = `🤔 Asking: ${questionText}...`; // Optionally disable other question buttons too this.followUpQuestionsElement.querySelectorAll('.follow-up-question-button').forEach(btn => btn.disabled = true); } else { // For custom questions, disable the input and button if (this.customQuestionInput) this.customQuestionInput.disabled = true; if (this.customQuestionButton) { this.customQuestionButton.disabled = true; this.customQuestionButton.textContent = 'Asking...'; } } this.conversationHistory.push({ question: questionText, answer: 'pending', uploadedImages: [...this.uploadedImageDataUrls], // Store a copy of the image URLs array reasoning: '' // Initialize reasoning for this turn }); // Construct the user message for the API history (raw question text) BEFORE clearing images const userMessageContentForHistory = [{ type: "text", text: questionText }]; if (this.uploadedImageDataUrls && this.uploadedImageDataUrls.length > 0) { this.uploadedImageDataUrls.forEach(url => { if (url.startsWith('data:application/pdf')) { // Extract filename from PDF preview if available const previewItem = this.followUpImageContainer?.querySelector(`[data-image-data-url="${CSS.escape(url)}"]`); const fileName = previewItem?.querySelector('.follow-up-pdf-preview span:last-child')?.textContent || 'document.pdf'; userMessageContentForHistory.push({ type: "file", file: { filename: fileName, file_data: url } }); } else { // Images use the existing format userMessageContentForHistory.push({ type: "image_url", image_url: { "url": url } }); } }); } const userApiMessage = { role: "user", content: userMessageContentForHistory }; // Create a new history array for the API call, including the new raw user message const historyForApiCall = [...this.qaConversationHistory, userApiMessage]; this._clearFollowUpImage(); // Clear preview after data is captured AND API message is constructed this._updateTooltipUI(); // Update UI to show pending state this.questions = []; // Clear suggested questions this._updateTooltipUI(); // Update UI again to remove suggested questions if (!apiKey) { showStatus('API key missing. Cannot answer question.', 'error'); this._updateConversationHistory(questionText, "Error: API Key missing.", ""); // Re-enable buttons if (!isMockEvent) { button.disabled = false; this.followUpQuestionsElement.querySelectorAll('.follow-up-question-button').forEach(btn => btn.disabled = false); } if (this.customQuestionInput) this.customQuestionInput.disabled = false; if (this.customQuestionButton) { this.customQuestionButton.disabled = false; this.customQuestionButton.textContent = 'Ask'; } this._clearFollowUpImage(); // Clear image even on error return; } if (!questionText) { console.error("Follow-up question text not found on button."); this._updateConversationHistory(questionText || "Error: Empty Question", "Error: Could not identify question.", ""); // Re-enable buttons if (!isMockEvent) { button.disabled = false; this.followUpQuestionsElement.querySelectorAll('.follow-up-question-button').forEach(btn => btn.disabled = false); } if (this.customQuestionInput) this.customQuestionInput.disabled = false; if (this.customQuestionButton) { this.customQuestionButton.disabled = false; this.customQuestionButton.textContent = 'Ask'; } this._clearFollowUpImage(); return; } const currentArticle = this.findCurrentArticleElement(); // We no longer need to pass original mediaUrls from cache, as they are in qaConversationHistory // const cachedData = tweetCache.get(this.tweetId); // const mediaUrls = cachedData?.mediaUrls || []; try { // Pass the augmented history to answerFollowUpQuestion answerFollowUpQuestion(this.tweetId, historyForApiCall, apiKey, currentArticle, this); } finally { // Removed button re-enabling logic from here. It will be handled by _finalizeFollowUpInteraction // called from answerFollowUpQuestion. } } _handleCustomQuestionClick(event) { // Add event parameter and prevent default if (event) { event.preventDefault(); event.stopPropagation(); } if (!this.customQuestionInput || !this.customQuestionButton) return; const questionText = this.customQuestionInput.value.trim(); const hasImages = this.uploadedImageDataUrls && this.uploadedImageDataUrls.length > 0; if (!questionText && !hasImages) { showStatus("Please enter a question or attach a file.", "warning"); this.customQuestionInput.focus(); return; } // If there's no text but there are images, use a placeholder space. const submissionText = questionText || (hasImages ? "[file only message]" : ""); // This reuses the logic from _handleFollowUpQuestionClick for sending the question // The actual API call happens there. We just need to trigger it. // Create a temporary "button" like object to pass to _handleFollowUpQuestionClick // or refactor to a common sending function. For now, let's simulate a click. const mockButton = { dataset: { questionText: submissionText }, disabled: false, textContent: '' }; // Temporarily disable suggested questions if any this.followUpQuestionsElement?.querySelectorAll('.follow-up-question-button').forEach(btn => btn.disabled = true); // Call the handler, it will manage UI updates and API call this._handleFollowUpQuestionClick({ target: mockButton, stopPropagation: () => {}, preventDefault: () => {} // Add preventDefault to mock event }); // Clear the input field after initiating the send for custom questions if (this.customQuestionInput) { this.customQuestionInput.value = ''; // Reset the textarea height to single row this.customQuestionInput.style.height = 'auto'; this.customQuestionInput.rows = 1; } } // --- New: Image Handling Methods for Follow-up --- _handleFollowUpImageSelect(event) { if (event) { event.preventDefault(); } const files = event.target.files; if (!files || files.length === 0) return; // Ensure the container is visible if we're adding images if (this.followUpImageContainer && files.length > 0) { this.followUpImageContainer.style.display = 'flex'; // Or 'block', depending on final styling } Array.from(files).forEach(file => { if (file && file.type.startsWith('image/')) { resizeImage(file, 1024) // Resize to max 1024px .then(resizedDataUrl => { this.uploadedImageDataUrls.push(resizedDataUrl); this._addPreviewToContainer(resizedDataUrl, 'image'); }) .catch(error => { console.error("Error resizing image:", error); showStatus(`Could not process image ${file.name}: ${error.message}`, "error"); }); } else if (file && file.type === 'application/pdf') { // Handle PDF files - convert to base64 data URL const reader = new FileReader(); reader.onload = (e) => { const dataUrl = e.target.result; this.uploadedImageDataUrls.push(dataUrl); // Using same array for simplicity this._addPreviewToContainer(dataUrl, 'pdf', file.name); }; reader.onerror = (error) => { console.error("Error reading PDF:", error); showStatus(`Could not process PDF ${file.name}: ${error.message}`, "error"); }; reader.readAsDataURL(file); } else if (file) { showStatus(`Skipping unsupported file type: ${file.name}`, "warning"); } }); // Reset file input to allow selecting the same file again if removed event.target.value = null; } _addPreviewToContainer(dataUrl, fileType = 'image', fileName = '') { if (!this.followUpImageContainer) return; const previewItem = document.createElement('div'); previewItem.className = 'follow-up-image-preview-item'; previewItem.dataset.imageDataUrl = dataUrl; // Store for easy removal if (fileType === 'pdf') { // For PDFs, show a PDF icon or text instead of image preview const pdfIcon = document.createElement('div'); pdfIcon.className = 'follow-up-pdf-preview'; pdfIcon.innerHTML = `<span style="font-size: 24px;">📄</span><br><span style="font-size: 11px; word-break: break-all;">${fileName || 'PDF'}</span>`; pdfIcon.style.textAlign = 'center'; pdfIcon.style.padding = '8px'; pdfIcon.style.width = '60px'; pdfIcon.style.height = '60px'; pdfIcon.style.display = 'flex'; pdfIcon.style.flexDirection = 'column'; pdfIcon.style.justifyContent = 'center'; pdfIcon.style.alignItems = 'center'; previewItem.appendChild(pdfIcon); } else { // Existing image preview const img = document.createElement('img'); img.src = dataUrl; img.className = 'follow-up-image-preview-thumbnail'; previewItem.appendChild(img); } const removeBtn = document.createElement('button'); removeBtn.textContent = '×'; // 'X' character for close removeBtn.className = 'follow-up-image-remove-btn'; removeBtn.title = 'Remove this file'; removeBtn.addEventListener('click', (e) => { e.preventDefault(); // Add this e.stopPropagation(); this._removeSpecificUploadedImage(dataUrl); }); previewItem.appendChild(removeBtn); this.followUpImageContainer.appendChild(previewItem); } _removeSpecificUploadedImage(imageDataUrl) { this.uploadedImageDataUrls = this.uploadedImageDataUrls.filter(url => url !== imageDataUrl); if (this.followUpImageContainer) { const previewItemToRemove = this.followUpImageContainer.querySelector(`div.follow-up-image-preview-item[data-image-data-url="${CSS.escape(imageDataUrl)}"]`); if (previewItemToRemove) { previewItemToRemove.remove(); } // Hide container if no images are left if (this.uploadedImageDataUrls.length === 0) { this.followUpImageContainer.style.display = 'none'; } } } _clearFollowUpImage() { this.uploadedImageDataUrls = []; // Reset the array if (this.followUpImageContainer) { this.followUpImageContainer.innerHTML = ''; // Clear all preview items this.followUpImageContainer.style.display = 'none'; // Hide the container } if (this.followUpImageInput) { this.followUpImageInput.value = null; // Clear the file input } } // --- End New --- _finalizeFollowUpInteraction() { // Re-enable suggested question buttons if they exist (new ones might have been rendered) if (this.followUpQuestionsElement) { this.followUpQuestionsElement.querySelectorAll('.follow-up-question-button').forEach(btn => { btn.disabled = false; // Note: Text content of suggested buttons is reset when new questions are rendered // by _updateTooltipUI, so no need to reset text like "Asking..." here. }); } // Re-enable custom question UI if it was the source if (this.currentFollowUpSource === 'custom') { if (this.customQuestionInput) { this.customQuestionInput.disabled = false; } if (this.customQuestionButton) { this.customQuestionButton.disabled = false; this.customQuestionButton.textContent = 'Ask'; } } // this._clearFollowUpImage(); // Clear any uploaded images for the follow-up -- MOVED this.currentFollowUpSource = null; // Reset the source tracker } // --- Public API --- /** * Finds a pending entry in the conversation history by question text and updates its answer. * Also updates the UI. * @param {string} question - The text of the question that was asked. * @param {string} answer - The new answer (or error message). * @param {string} [reasoning=''] - Optional reasoning text associated with the answer. */ _updateConversationHistory(question, answer, reasoning = '') { const entryIndex = this.conversationHistory.findIndex(turn => turn.question === question && turn.answer === 'pending'); if (entryIndex !== -1) { this.conversationHistory[entryIndex].answer = answer; this.conversationHistory[entryIndex].reasoning = reasoning; // Store reasoning this._updateTooltipUI(); // Refresh the view to show the updated answer } else { console.warn(`[ScoreIndicator ${this.tweetId}] Could not find pending history entry for question: "${question}"`); // Optionally, append as a new entry if not found, though this might indicate a logic error // this.conversationHistory.push({ question: question, answer: answer }); // this._updateTooltipUI(); } } /** * Updates the visual display of the last answer element during streaming * without changing the underlying conversationHistory state. * @param {string} streamingText - The current aggregated text from the stream. * @param {string} [reasoningText=''] - Optional reasoning text from the stream. */ _renderStreamingAnswer(streamingText, reasoningText = '') { if (!this.conversationContainerElement) return; // Find the last conversation turn element const conversationTurns = this.conversationContainerElement.querySelectorAll('.conversation-turn'); const lastTurnElement = conversationTurns.length > 0 ? conversationTurns[conversationTurns.length - 1] : null; if (!lastTurnElement) { console.warn(`[ScoreIndicator ${this.tweetId}] Could not find last conversation turn to render streaming answer.`); return; } // Ensure the corresponding state is actually pending before updating visuals const lastHistoryEntry = this.conversationHistory.length > 0 ? this.conversationHistory[this.conversationHistory.length -1] : null; if (!(lastHistoryEntry && lastHistoryEntry.answer === 'pending')) { console.warn(`[ScoreIndicator ${this.tweetId}] Attempted to render streaming answer, but last history entry is not pending.`); return; } // --- Handle Streaming Reasoning Container --- let streamingReasoningContainer = lastTurnElement.querySelector('.streaming-reasoning-container'); const hasReasoning = reasoningText && reasoningText.trim() !== ''; if (hasReasoning && !streamingReasoningContainer) { // Create streaming reasoning container if it doesn't exist streamingReasoningContainer = document.createElement('div'); streamingReasoningContainer.className = 'streaming-reasoning-container active'; streamingReasoningContainer.style.display = 'block'; const streamingReasoningText = document.createElement('div'); streamingReasoningText.className = 'streaming-reasoning-text'; streamingReasoningContainer.appendChild(streamingReasoningText); // Insert before the answer element const answerElement = lastTurnElement.querySelector('.conversation-answer'); if (answerElement) { lastTurnElement.insertBefore(streamingReasoningContainer, answerElement); } else { lastTurnElement.appendChild(streamingReasoningContainer); } } // Update streaming reasoning text if present if (streamingReasoningContainer && hasReasoning) { const streamingTextElement = streamingReasoningContainer.querySelector('.streaming-reasoning-text'); if (streamingTextElement) { // Show only the rightmost N characters if too long const maxDisplayLength = 200; // Characters to display let displayText = reasoningText; if (reasoningText.length > maxDisplayLength) { displayText = reasoningText.slice(-maxDisplayLength); } streamingTextElement.textContent = displayText; } } // --- Handle Reasoning Dropdown (hidden during streaming, will be shown on completion) --- let reasoningDropdown = lastTurnElement.querySelector('.reasoning-dropdown'); if (reasoningDropdown) { // Hide the dropdown during streaming reasoningDropdown.style.display = 'none'; } // --- Handle Answer Text --- const lastAnswerElement = lastTurnElement.querySelector('.conversation-answer'); if (lastAnswerElement) { // Format the streaming answer const formattedStreamingAnswer = streamingText .replace(/```([\s\S]*?)```/g, (m, code) => `<pre><code>${code.replace(/</g,'<').replace(/>/g,'>')}</code></pre>`) .replace(/</g, '<').replace(/>/g, '>') // Escape potential raw HTML first .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="ai-generated-link">$1</a>') .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*(.*?)\*/g, '<em>$1</em>') .replace(/`([^`]+)`/g, '<code>$1</code>') // Process Markdown Tables before line breaks .replace(/^\|(.+)\|\r?\n\|([\s\|\-:]+)\|\r?\n(\|(?:.+)\|\r?\n?)+/gm, (match) => { const rows = match.trim().split('\n'); const headerRow = rows[0]; // const separatorRow = rows[1]; // Not strictly needed here for formatting const bodyRows = rows.slice(2); let html = '<table class="markdown-table">'; html += '<thead><tr>'; headerRow.slice(1, -1).split('|').forEach(cell => { html += `<th>${cell.trim()}</th>`; }); html += '</tr></thead>'; html += '<tbody>'; bodyRows.forEach(rowStr => { if (!rowStr.trim()) return; html += '<tr>'; rowStr.slice(1, -1).split('|').forEach(cell => { html += `<td>${cell.trim()}</td>`; }); html += '</tr>'; }); html += '</tbody></table>'; return html; }) .replace(/\n/g, '<br>'); // Update the innerHTML directly, adding the cursor lastAnswerElement.innerHTML = `<strong>AI:</strong> ${formattedStreamingAnswer}<em class="pending-cursor">|</em>`; } else { console.warn(`[ScoreIndicator ${this.tweetId}] Could not find answer element in last conversation turn.`); } // Ensure autoscroll if needed if (this.autoScroll) { this._performAutoScroll(); } // In _renderStreamingAnswer, after updating the answer, call this._performConversationAutoScroll() instead of this._performAutoScroll(). // Remove or comment out the call to this._performAutoScroll() in _renderStreamingAnswer. this._performConversationAutoScroll(); } /** * Updates the indicator's state and refreshes the UI. * @param {object} options * @param {string} [options.status] - New status ('pending', 'streaming', 'rated', 'error', 'cached', 'blacklisted'). * @param {number|null} [options.score] - New score. * @param {string} [options.description] - New description text. * @param {string} [options.reasoning] - New reasoning text. * @param {object|null} [options.metadata] - New metadata object. * @param {string[]} [options.questions] - New follow-up questions. */ update({ status, score = null, description = '', reasoning = '', metadata = null, questions = undefined }) { // console.log(`[ScoreIndicator ${this.tweetId}] Updating state - Status: ${status}, Score: ${score}`); const statusChanged = status !== undefined && this.status !== status; const scoreChanged = score !== null && this.score !== score; const descriptionChanged = description !== '' && this.description !== description; const reasoningChanged = reasoning !== '' && this.reasoning !== reasoning; const metadataChanged = metadata !== null && JSON.stringify(this.metadata) !== JSON.stringify(metadata); const questionsChanged = questions !== undefined && JSON.stringify(this.questions) !== JSON.stringify(questions); // Conversation history updates are handled separately now // Only update if something actually changed if (!statusChanged && !scoreChanged && !descriptionChanged && !reasoningChanged && !metadataChanged && !questionsChanged) { // console.log(`[ScoreIndicator ${this.tweetId}] No state change detected.`); return; } if (statusChanged) this.status = status; // Ensure score is null if status implies it (e.g., pending without previous score) if (scoreChanged || statusChanged) { this.score = (this.status === 'pending' || this.status === 'error') ? score : // Allow score display for error state if provided (this.status === 'streaming' && score === null) ? this.score : // Keep existing score during streaming if new one is null score; } if (descriptionChanged) this.description = description; if (reasoningChanged) this.reasoning = reasoning; if (metadataChanged) this.metadata = metadata; if (questionsChanged) this.questions = questions; // Update autoScroll state based on new status BEFORE UI updates if (statusChanged) { const shouldAutoScroll = (this.status === 'pending' || this.status === 'streaming'); if (this.autoScroll !== shouldAutoScroll) { this.autoScroll = shouldAutoScroll; // Add null check before accessing dataset if (this.tooltipElement) { this.tooltipElement.dataset.autoScroll = this.autoScroll ? 'true' : 'false'; } } } // Update UI elements if (statusChanged || scoreChanged) { this._updateIndicatorUI(); } // Update tooltip if content changed or if visibility/scrolling might need adjustment if (descriptionChanged || reasoningChanged || statusChanged || metadataChanged || questionsChanged) { this._updateTooltipUI(); // This handles content and auto-scroll if visible } else { // If only score changed, ensure scroll button visibility is correct this._updateScrollButtonVisibility(); } } /** Shows the tooltip and positions it correctly. */ show() { if (!this.tooltipElement) return; // console.log(`[ScoreIndicator ${this.tweetId}] Showing tooltip`); this.isVisible = true; this.tooltipElement.style.display = 'flex'; // MODIFIED: Was 'block', needs to be 'flex' for new layout this._setPosition(); // Calculate and apply position // Handle auto-scroll on show if needed if (this.autoScroll && (this.status === 'streaming' || this.status === 'pending')) { this._performAutoScroll(); } // Ensure scroll button visibility is correct on show this._updateScrollButtonVisibility(); } /** Hides the tooltip unless it's pinned. */ hide() { if (!this.isPinned && this.tooltipElement) { // console.log(`[ScoreIndicator ${this.tweetId}] Hiding tooltip`); this.isVisible = false; this.tooltipElement.style.display = 'none'; } else if (this.isPinned) { // console.log(`[ScoreIndicator ${this.tweetId}] Attempted to hide pinned tooltip`); } } /** Toggles the tooltip's visibility. */ toggle() { if (this.isVisible && !this.isPinned) { this.hide(); } else { // If pinned and visible, clicking should maybe unpin? Decided against for now. this.show(); // show() handles positioning and makes it visible } } /** Pins the tooltip open. */ pin() { if (!this.tooltipElement || !this.pinButton) return; // console.log(`[ScoreIndicator ${this.tweetId}] Pinning tooltip`); this.isPinned = true; this.tooltipElement.classList.add('pinned'); this.pinButton.innerHTML = '📍'; // Use the filled pin icon this.pinButton.title = 'Unpin tooltip'; // Tooltip remains visible even if mouse leaves } /** Unpins the tooltip, allowing it to be hidden automatically. */ unpin() { if (!this.tooltipElement || !this.pinButton) return; // console.log(`[ScoreIndicator ${this.tweetId}] Unpinning tooltip`); this.isPinned = false; this.tooltipElement.classList.remove('pinned'); this.pinButton.innerHTML = '📌'; // Use the outline pin icon this.pinButton.title = 'Pin tooltip (prevents auto-closing)'; // Check if mouse is currently outside the tooltip/indicator; if so, hide it now setTimeout(() => { if (this.tooltipElement && !this.tooltipElement.matches(':hover') && this.indicatorElement && !this.indicatorElement.matches(':hover')) { this.hide(); } }, 0); } // --- New Event Handler for Close Button --- _handleCloseClick(e) { if (e) { e.stopPropagation(); } this.hide(); // Simply hide the tooltip } // --- End New Event Handler --- /** Removes the indicator, tooltip, and listeners from the DOM and registry. */ destroy() { // console.log(`[ScoreIndicator ${this.tweetId}] Destroying...`); // Clean up any active streaming request for this tweet if (window.activeStreamingRequests && window.activeStreamingRequests[this.tweetId]) { console.log(`Cleaning up active streaming request for tweet ${this.tweetId}`); window.activeStreamingRequests[this.tweetId].abort(); delete window.activeStreamingRequests[this.tweetId]; } // Remove event listeners first to prevent errors during removal this.indicatorElement?.removeEventListener('mouseenter', this._handleMouseEnter); this.indicatorElement?.removeEventListener('mouseleave', this._handleMouseLeave); this.indicatorElement?.removeEventListener('click', this._handleIndicatorClick); this.tooltipElement?.removeEventListener('mouseenter', this._handleTooltipMouseEnter); this.tooltipElement?.removeEventListener('mouseleave', this._handleTooltipMouseLeave); this.tooltipScrollableContentElement?.removeEventListener('scroll', this._handleTooltipScroll.bind(this)); this.pinButton?.removeEventListener('click', this._handlePinClick.bind(this)); this.copyButton?.removeEventListener('click', this._handleCopyClick.bind(this)); this.tooltipCloseButton?.removeEventListener('click', this._handleCloseClick.bind(this)); this.reasoningToggle?.removeEventListener('click', this._handleReasoningToggleClick.bind(this)); this.scrollButton?.removeEventListener('click', this._handleScrollButtonClick.bind(this)); this.followUpQuestionsElement?.removeEventListener('click', this._handleFollowUpQuestionClick.bind(this)); this.customQuestionButton?.removeEventListener('click', this._handleCustomQuestionClick.bind(this)); this.customQuestionInput?.removeEventListener('keydown', this._boundHandlers.handleKeyDown); // Remove mobile-specific event listeners if (isMobileDevice()) { if (this.customQuestionInput && this._boundHandlers.handleMobileFocus) { this.customQuestionInput.removeEventListener('focus', this._boundHandlers.handleMobileFocus); this.customQuestionInput.removeEventListener('touchstart', this._boundHandlers.handleMobileTouchStart, { passive: false }); } // Remove follow-up questions touch handlers if (this.followUpQuestionsElement && this._boundHandlers.handleFollowUpTouchStart) { this.followUpQuestionsElement.removeEventListener('touchstart', this._boundHandlers.handleFollowUpTouchStart, { passive: false }); this.followUpQuestionsElement.removeEventListener('touchend', this._boundHandlers.handleFollowUpTouchEnd, { passive: false }); } } this.metadataToggle?.removeEventListener('click', this._handleMetadataToggleClick.bind(this)); this.refreshButton?.removeEventListener('click', this._handleRefreshClick.bind(this)); this.rateButton?.removeEventListener('click', this._handleRateClick.bind(this)); // Remove image button listeners if (this.attachImageButton) { this.attachImageButton.removeEventListener('click', this._boundHandlers.handleAttachImageClick); } if (this.followUpImageInput) { this.followUpImageInput.removeEventListener('change', this._handleFollowUpImageSelect.bind(this)); } // Remove conversation reasoning toggle listener if (this.conversationContainerElement && this._boundHandlers.handleConversationReasoningToggle) { this.conversationContainerElement.removeEventListener('click', this._boundHandlers.handleConversationReasoningToggle); } this.indicatorElement?.remove(); this.tooltipElement?.remove(); // Remove from registry ScoreIndicatorRegistry.remove(this.tweetId); // Update dataset attribute on article (if it still exists) const currentArticle = this.findCurrentArticleElement(); // Find before nullifying if (currentArticle) { delete currentArticle.dataset.hasScoreIndicator; // delete currentArticle.dataset.indicatorManaged; // No longer using this } // Nullify references to help garbage collection this.tweetArticle = null; this.indicatorElement = null; this.tooltipElement = null; this.pinButton = null; this.copyButton = null; this.tooltipCloseButton = null; this.reasoningToggle = null; this.scrollButton = null; this.conversationContainerElement = null; this.followUpQuestionsElement = null; this.customQuestionContainer = null; this.customQuestionInput = null; this.customQuestionButton = null; // --- New: Nullify Image Upload Elements --- this.followUpImageContainer = null; this.followUpImageInput = null; this.uploadedImageDataUrls = []; // Ensure it's reset here too this.refreshButton = null; // Nullify refresh button this.rateButton = null; // Nullify rate button // --- End New --- // --- Nullify Metadata Dropdown Elements --- this.metadataDropdown = null; this.metadataToggle = null; this.metadataArrow = null; this.metadataContent = null; // this.metadataElement is already nulled above as part of original cleanup this.tooltipScrollableContentElement = null; // NEW: cleanup } /** Ensures the indicator element is attached to the correct current article element. */ ensureIndicatorAttached() { if (!this.indicatorElement) return; // Nothing to attach const currentArticle = this.findCurrentArticleElement(); if (!currentArticle) { return; } // Check if the indicator is already in the *correct* article if (this.indicatorElement.parentElement !== currentArticle) { // console.log(`[ScoreIndicator ${this.tweetId}] Re-attaching indicator to current article.`); // Ensure parent is positioned const currentPosition = window.getComputedStyle(currentArticle).position; if (currentPosition !== 'relative' && currentPosition !== 'absolute' && currentPosition !== 'fixed' && currentPosition !== 'sticky') { currentArticle.style.position = 'relative'; } currentArticle.appendChild(this.indicatorElement); } } /** Finds the current DOM element for the tweet article based on tweetId. */ findCurrentArticleElement() { const timeline = document.querySelector('main') || document.querySelector('div[data-testid="primaryColumn"]'); if (!timeline) return null; // Try finding via a link containing the tweetId first const linkSelector = `a[href*="/status/${this.tweetId}"]`; const linkElement = timeline.querySelector(linkSelector); const article = linkElement?.closest('article[data-testid="tweet"]'); if (article) { // Verify the found article's ID matches, just in case the link wasn't the permalink if (getTweetID(article) === this.tweetId) { return article; } } // Fallback: Iterate through all articles if specific link not found // This is less efficient but necessary if the ID isn't easily queryable const articles = timeline.querySelectorAll('article[data-testid="tweet"]'); for (const art of articles) { if (getTweetID(art) === this.tweetId) { return art; } } return null; // Not found } /** * Updates the indicator's state after an initial review and builds the conversation history. * @param {object} params * @param {string} params.fullContext - The full text context of the tweet. * @param {string[]} params.mediaUrls - Array of media URLs from the tweet. * @param {string} params.apiResponseContent - The raw content from the API response. * @param {string} params.reviewSystemPrompt - The system prompt used for the initial review. * @param {string} params.followUpSystemPrompt - The system prompt to be used for follow-ups. * @param {string} [params.userInstructions] - The user's custom instructions for rating tweets. */ updateInitialReviewAndBuildHistory({ fullContext, mediaUrls, apiResponseContent, reviewSystemPrompt, followUpSystemPrompt, userInstructions = '' }) { // Parse apiResponseContent for analysis, score, and initial questions const analysisMatch = apiResponseContent.match(/<ANALYSIS>([\s\S]*?)<\/ANALYSIS>/); const scoreMatch = apiResponseContent.match(/<SCORE>\s*SCORE_(\d+)\s*<\/SCORE>/); // extractFollowUpQuestions function is defined in api.js, assuming it's globally available const initialQuestions = extractFollowUpQuestions(apiResponseContent); this.score = scoreMatch ? parseInt(scoreMatch[1], 10) : null; this.description = analysisMatch ? analysisMatch[1].trim() : apiResponseContent; // Fallback to full content this.questions = initialQuestions; this.status = this.score !== null ? 'rated' : 'error'; // Or some other logic for status // Construct qaConversationHistory const userMessageContent = [{ type: "text", text: fullContext }]; mediaUrls.forEach(url => { userMessageContent.push({ type: "image_url", image_url: { "url": url } }); }); // Substitute user instructions into the follow-up system prompt const followUpSystemPromptWithInstructions = followUpSystemPrompt.replace( '{USER_INSTRUCTIONS_PLACEHOLDER}', userInstructions || 'Rate the tweet on a scale from 1 to 10 based on its clarity, insight, creativity, and overall quality.' ); this.qaConversationHistory = [ { role: "system", content: [{ type: "text", text: reviewSystemPrompt }] }, { role: "user", content: userMessageContent }, { role: "assistant", content: [{ type: "text", text: apiResponseContent }] }, { role: "system", content: [{ type: "text", text: followUpSystemPromptWithInstructions }] } ]; // Update UI elements this._updateIndicatorUI(); this._updateTooltipUI(); } /** * Updates the indicator's state after a follow-up question has been answered. * @param {object} params * @param {string} params.assistantResponseContent - The raw content of the AI's response. * @param {object[]} params.updatedQaHistory - The fully updated qaConversationHistory array. */ updateAfterFollowUp({ assistantResponseContent, updatedQaHistory }) { this.qaConversationHistory = updatedQaHistory; // Parse assistantResponseContent for the answer and new follow-up questions const answerMatch = assistantResponseContent.match(/<ANSWER>([\s\S]*?)<\/ANSWER>/); const newFollowUpQuestions = extractFollowUpQuestions(assistantResponseContent); const answerText = answerMatch ? answerMatch[1].trim() : assistantResponseContent; // Fallback // Update this.questions for the UI buttons this.questions = newFollowUpQuestions; // Update the last turn in this.conversationHistory (for UI rendering) if (this.conversationHistory.length > 0) { const lastTurn = this.conversationHistory[this.conversationHistory.length - 1]; if (lastTurn.answer === 'pending') { lastTurn.answer = answerText; // Reasoning should already be set by answerFollowUpQuestion during streaming } } // Remove streaming reasoning container and create proper reasoning dropdown this._convertStreamingToDropdown(); // Refresh the tooltip UI this._updateTooltipUI(); } /** * Converts the streaming reasoning container to a proper reasoning dropdown after streaming completes. * @private */ _convertStreamingToDropdown() { if (!this.conversationContainerElement) return; const conversationTurns = this.conversationContainerElement.querySelectorAll('.conversation-turn'); const lastTurnElement = conversationTurns.length > 0 ? conversationTurns[conversationTurns.length - 1] : null; if (!lastTurnElement) return; // Find and remove streaming container const streamingContainer = lastTurnElement.querySelector('.streaming-reasoning-container'); if (streamingContainer) { streamingContainer.remove(); } // Get the reasoning from the last conversation history turn const lastHistoryEntry = this.conversationHistory.length > 0 ? this.conversationHistory[this.conversationHistory.length - 1] : null; if (!lastHistoryEntry || !lastHistoryEntry.reasoning || lastHistoryEntry.reasoning.trim() === '') { return; // No reasoning to show } // Create reasoning dropdown if it doesn't exist let reasoningDropdown = lastTurnElement.querySelector('.reasoning-dropdown'); if (!reasoningDropdown) { reasoningDropdown = document.createElement('div'); reasoningDropdown.className = 'reasoning-dropdown conversation-reasoning'; const reasoningToggle = document.createElement('div'); reasoningToggle.className = 'reasoning-toggle'; const reasoningArrow = document.createElement('span'); reasoningArrow.className = 'reasoning-arrow'; reasoningArrow.textContent = '▶'; reasoningToggle.appendChild(reasoningArrow); reasoningToggle.appendChild(document.createTextNode(' Show Reasoning Trace')); const reasoningContent = document.createElement('div'); reasoningContent.className = 'reasoning-content'; const reasoningTextElement = document.createElement('p'); reasoningTextElement.className = 'reasoning-text'; reasoningContent.appendChild(reasoningTextElement); reasoningDropdown.appendChild(reasoningToggle); reasoningDropdown.appendChild(reasoningContent); // Insert before the answer element const answerElement = lastTurnElement.querySelector('.conversation-answer'); if (answerElement) { lastTurnElement.insertBefore(reasoningDropdown, answerElement); } else { lastTurnElement.appendChild(reasoningDropdown); } // Add toggle listener reasoningToggle.addEventListener('click', (e) => { e.stopPropagation(); const dropdown = e.target.closest('.reasoning-dropdown'); const content = dropdown?.querySelector('.reasoning-content'); const arrow = dropdown?.querySelector('.reasoning-arrow'); if (!dropdown || !content || !arrow) return; const isExpanded = dropdown.classList.toggle('expanded'); arrow.textContent = isExpanded ? '▼' : '▶'; content.style.maxHeight = isExpanded ? '200px' : '0'; content.style.padding = isExpanded ? '8px' : '0 8px'; }); } // Update reasoning content const reasoningTextElement = reasoningDropdown.querySelector('.reasoning-text'); if (reasoningTextElement) { const formattedReasoning = formatTooltipDescription("", lastHistoryEntry.reasoning).reasoning; reasoningTextElement.innerHTML = formattedReasoning; } // Show the dropdown reasoningDropdown.style.display = 'block'; } /** * Rehydrates the ScoreIndicator instance from cached data. * @param {object} cachedData - The cached data object. */ rehydrateFromCache(cachedData) { this.score = cachedData.score; this.description = cachedData.description; // This should be the analysis part this.reasoning = cachedData.reasoning; this.questions = cachedData.questions || []; this.status = cachedData.status || (cachedData.score !== null ? (cachedData.fromStorage ? 'cached' : 'rated') : 'error'); this.metadata = cachedData.metadata || null; this.qaConversationHistory = cachedData.qaConversationHistory || []; this.isPinned = cachedData.isPinned || false; // Assuming we might cache pin state // Rebuild this.conversationHistory (for UI) from qaConversationHistory this.conversationHistory = []; if (this.qaConversationHistory.length > 0) { let currentQuestion = null; let currentUploadedImages = []; // Start iterating after the initial assistant review and the follow-up system prompt // Initial structure: [SysReview, UserTweet, AssReview, SysFollowUp, UserQ1, AssA1, ...] // We look for UserQ -> AssA pairs let startIndex = 0; for(let i=0; i < this.qaConversationHistory.length; i++) { if (this.qaConversationHistory[i].role === 'system' && this.qaConversationHistory[i].content[0].text.includes('FOLLOW_UP_SYSTEM_PROMPT')) { startIndex = i + 1; break; } // Fallback if FOLLOW_UP_SYSTEM_PROMPT is not found (e.g. very old cache) if (i === 3 && this.qaConversationHistory[i].role === 'system') { startIndex = i + 1; } } for (let i = startIndex; i < this.qaConversationHistory.length; i++) { const message = this.qaConversationHistory[i]; if (message.role === 'user') { // Find the text part of the user's message const textContent = message.content.find(c => c.type === 'text'); currentQuestion = textContent ? textContent.text : "[Question not found]"; // Extract uploaded images if any currentUploadedImages = message.content .filter(c => c.type === 'image_url' && c.image_url && c.image_url.url.startsWith('data:image')) .map(c => c.image_url.url); } else if (message.role === 'assistant' && currentQuestion) { const assistantTextContent = message.content.find(c => c.type === 'text'); const assistantAnswer = assistantTextContent ? assistantTextContent.text : "[Answer not found]"; // Attempt to parse out just the answer part for the UI history const answerMatch = assistantAnswer.match(/<ANSWER>([\s\S]*?)<\/ANSWER>/); const uiAnswer = answerMatch ? answerMatch[1].trim() : assistantAnswer; this.conversationHistory.push({ question: currentQuestion, answer: uiAnswer, uploadedImages: currentUploadedImages, reasoning: '' // Reasoning extraction from assistant's full response for UI needs more logic }); currentQuestion = null; // Reset for the next pair currentUploadedImages = []; } } } if (this.isPinned) { this.pinButton.innerHTML = '📍'; this.tooltipElement?.classList.add('pinned'); } else { this.pinButton.innerHTML = '📌'; this.tooltipElement?.classList.remove('pinned'); } this._updateIndicatorUI(); this._updateTooltipUI(); } _handleMetadataToggleClick(e) { if (e) { e.stopPropagation(); } if (!this.metadataDropdown || !this.metadataContent || !this.metadataArrow) return; // Store scroll position before toggle const scrollTop = this.tooltipScrollableContentElement?.scrollTop || 0; const isExpanded = this.metadataDropdown.classList.toggle('expanded'); this.metadataArrow.textContent = isExpanded ? '▼' : '▶'; if (isExpanded) { this.metadataContent.style.maxHeight = '300px'; // Or appropriate max-height, matching reasoning for consistency this.metadataContent.style.padding = '10px'; // Match reasoning } else { this.metadataContent.style.maxHeight = '0'; this.metadataContent.style.padding = '0 10px'; // Match reasoning } // Restore scroll position on mobile to prevent jumping if (isMobileDevice() && this.tooltipScrollableContentElement) { requestAnimationFrame(() => { if (this.tooltipScrollableContentElement) { this.tooltipScrollableContentElement.scrollTop = scrollTop; } }); } } _handleRefreshClick(e) { e && e.stopPropagation(); if (!this.tweetId) return; // Abort any streaming requests if active if (window.activeStreamingRequests && window.activeStreamingRequests[this.tweetId]) { window.activeStreamingRequests[this.tweetId].abort(); delete window.activeStreamingRequests[this.tweetId]; } // Clear cache entry if it exists if (tweetCache.has(this.tweetId)) { tweetCache.delete(this.tweetId); } // Remove from processedTweets set if it exists if (processedTweets.has(this.tweetId)) { processedTweets.delete(this.tweetId); } // Find current tweet article and destroy this indicator const currentArticle = this.findCurrentArticleElement(); this.destroy(); // Re-process the tweet if found and scheduleTweetProcessing is available if (currentArticle && typeof scheduleTweetProcessing === 'function') { scheduleTweetProcessing(currentArticle); } } _handleRateClick(e) { e && e.stopPropagation(); if (!this.tweetId) return; // Change status to pending and trigger rating this.update({ status: 'pending', score: null, description: 'Rating tweet...', reasoning: '', questions: [] }); // Find current tweet article and trigger manual rating const currentArticle = this.findCurrentArticleElement(); if (currentArticle && typeof scheduleTweetProcessing === 'function') { // Remove from processedTweets to allow re-processing if (processedTweets.has(this.tweetId)) { processedTweets.delete(this.tweetId); } scheduleTweetProcessing(currentArticle, true); // rateAnyway = true } } /** * Handle scroll events in the conversation history area for granular auto-scroll. */ _handleConversationScroll() { if (!this.conversationContainerElement) return; const isNearBottom = this.conversationContainerElement.scrollHeight - this.conversationContainerElement.scrollTop - this.conversationContainerElement.clientHeight < 40; if (!isNearBottom) { if (this.autoScrollConversation) { this.autoScrollConversation = false; } } else { if (!this.autoScrollConversation) { this.autoScrollConversation = true; } } } /** * Auto-scroll the conversation history area to the bottom if allowed. */ _performConversationAutoScroll() { if (!this.conversationContainerElement || !this.autoScrollConversation) return; requestAnimationFrame(() => { this.conversationContainerElement.scrollTo({ top: this.conversationContainerElement.scrollHeight, behavior: 'instant' }); }); } } // --- Registry for Managing Instances --- const ScoreIndicatorRegistry = { managers: new Map(), /** * Gets an existing manager or creates a new one. * Ensures only one manager exists per tweetId. * @param {string} tweetId * @param {Element} [tweetArticle=null] - Required if creating a new instance. * @returns {ScoreIndicator | null} */ get(tweetId, tweetArticle = null) { if (!tweetId) { console.error("[Registry] Attempted to get instance with invalid tweetId:", tweetId); return null; } if (this.managers.has(tweetId)) { const existingManager = this.managers.get(tweetId); // Ensure the existing manager's article is still valid if possible return existingManager; } else if (tweetArticle) { try { // Double-check if an indicator element *already exists* for this tweet ID, // potentially created outside the registry (shouldn't happen with proper usage). const existingIndicator = tweetArticle.querySelector(`.score-indicator[data-tweet-id="${tweetId}"]`); const existingTooltip = document.querySelector(`.score-description[data-tweet-id="${tweetId}"]`); if (existingIndicator || existingTooltip) { console.warn(`[Registry] Found existing indicator/tooltip elements for tweet ${tweetId} outside registry. Removing them before creating new manager.`); existingIndicator?.remove(); existingTooltip?.remove(); } // Create new instance. The constructor handles adding itself to the registry. return new ScoreIndicator(tweetArticle); } catch (e) { console.error(`[Registry] Error creating ScoreIndicator for ${tweetId}:`, e); return null; } } // If no instance exists and no article provided to create one // console.log(`[Registry] No instance found for ${tweetId} and no article provided.`); return null; }, /** * Adds an instance to the registry (called by constructor). * @param {string} tweetId * @param {ScoreIndicator} instance */ add(tweetId, instance) { if (this.managers.has(tweetId)) { console.warn(`[Registry] Overwriting existing manager for tweet ${tweetId}. This may indicate an issue.`); // Optionally destroy the old one first: this.managers.get(tweetId).destroy(); } this.managers.set(tweetId, instance); // console.log(`[Registry] Added indicator for ${tweetId}. Total: ${this.managers.size}`); }, /** * Removes an instance from the registry (called by destroy method). * @param {string} tweetId */ remove(tweetId) { if (this.managers.has(tweetId)) { this.managers.delete(tweetId); } }, /** * Cleans up managers whose corresponding tweet articles are no longer in the main timeline DOM. */ cleanupOrphaned() { let removedCount = 0; const observedTimeline = document.querySelector('main') || document.querySelector('div[data-testid="primaryColumn"]'); if (!observedTimeline) return; // Collect IDs of tweet articles currently visible in the timeline const visibleTweetIds = new Set(); observedTimeline.querySelectorAll('article[data-testid="tweet"]').forEach(article => { const id = getTweetID(article); if (id) visibleTweetIds.add(id); }); for (const [tweetId, manager] of this.managers.entries()) { const isConnected = manager.indicatorElement?.isConnected; const isVisible = visibleTweetIds.has(tweetId); if (!isConnected || !isVisible) { manager.destroy(); // Destroy calls remove() removedCount++; } } }, /** * Destroys all managed indicators. Useful for full cleanup on script unload/major UI reset. */ destroyAll() { console.log(`[Registry] Destroying all ${this.managers.size} indicators.`); // Iterate over a copy of values, as destroy() modifies the map [...this.managers.values()].forEach(manager => manager.destroy()); this.managers.clear(); // Ensure map is empty } }; // --- Helper Functions (Assume these are globally available due to Tampermonkey) --- // function getTweetID(tweetArticle) { ... } // From domScraper.js // function isMobileDevice() { ... } // From ui.js // Helper for formatting description/reasoning (can be kept here or moved) function formatTooltipDescription(description = "", reasoning = "") { // Only format description if it's not the placeholder let formattedDescription = description === "*Waiting for analysis...*" ? description : (description || "*waiting for content...*") // Format fenced code blocks ```code``` .replace(/```([\s\S]*?)```/g, (match, code) => `<pre><code>${code.replace(/</g,'<').replace(/>/g,'>')}</code></pre>`) .replace(/</g, '<').replace(/>/g, '>') // Escape HTML tags first // Hyperlinks [text](url) .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="ai-generated-link">$1</a>') .replace(/^# (.*$)/gm, '<h1>$1</h1>') .replace(/^## (.*$)/gm, '<h2>$1</h2>') .replace(/^### (.*$)/gm, '<h3>$1</h3>') .replace(/^#### (.*$)/gm, '<h4>$1</h4>') .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') // Bold .replace(/\*(.*?)\*/g, '<em>$1</em>') // Italic .replace(/`([^`]+)`/g, '<code>$1</code>') // Inline code .replace(/SCORE_(\d+)/g, '<span class="score-highlight">SCORE: $1</span>') // Score highlight class // Process Markdown Tables before line breaks .replace(/^\|(.+)\|\r?\n\|([\s\|\-:]+)\|\r?\n(\|(?:.+)\|\r?\n?)+/gm, (match) => { const rows = match.trim().split('\n'); const headerRow = rows[0]; const separatorRow = rows[1]; // We use this to confirm it's a table const bodyRows = rows.slice(2); let html = '<table class="markdown-table">'; // Header html += '<thead><tr>'; headerRow.slice(1, -1).split('|').forEach(cell => { html += `<th>${cell.trim()}</th>`; }); html += '</tr></thead>'; // Body html += '<tbody>'; bodyRows.forEach(rowStr => { if (!rowStr.trim()) return; // Skip empty lines that might be caught by regex html += '<tr>'; rowStr.slice(1, -1).split('|').forEach(cell => { html += `<td>${cell.trim()}</td>`; }); html += '</tr>'; }); html += '</tbody></table>'; return html; }) .replace(/\n\n/g, '<br><br>') // Paragraph breaks .replace(/\n/g, '<br>'); // Line breaks let formattedReasoning = ''; if (reasoning && reasoning.trim()) { formattedReasoning = reasoning .replace(/\\n/g, '\n') // Convert literal '\n' to actual newline characters // Format fenced code blocks ```code``` .replace(/```([\s\S]*?)```/g, (m, code) => `<pre><code>${code.replace(/</g,'<').replace(/>/g,'>')}</code></pre>`) .replace(/</g, '<').replace(/>/g, '>') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="ai-generated-link">$1</a>') .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*(.*?)\*/g, '<em>$1</em>') .replace(/`([^`]+)`/g, '<code>$1</code>') .replace(/\n\n/g, '<br><br>') .replace(/\n/g, '<br>'); } // Return both, even though caller might only use one return { description: formattedDescription, reasoning: formattedReasoning }; } // ----- ui/ui.js ----- /** * Toggles the visibility of an element and updates the corresponding toggle button text. * @param {HTMLElement} element - The element to toggle. * @param {HTMLElement} toggleButton - The button that controls the toggle. * @param {string} openText - Text for the button when the element is open. * @param {string} closedText - Text for the button when the element is closed. */ function toggleElementVisibility(element, toggleButton, openText, closedText) { if (!element || !toggleButton) return; const isCurrentlyHidden = element.classList.contains('hidden'); // Update button text immediately toggleButton.innerHTML = isCurrentlyHidden ? openText : closedText; if (isCurrentlyHidden) { // Opening element.style.display = 'flex'; // Force reflow element.offsetHeight; element.classList.remove('hidden'); } else { // Closing element.classList.add('hidden'); // Wait for animation to complete before changing display setTimeout(() => { if (element.classList.contains('hidden')) { element.style.display = 'none'; } }, 500); // Match the CSS transition duration } // Update opacity for settings-toggle on mobile based on settings-container state if (element.id === 'settings-container' && toggleButton.id === 'settings-toggle') { if (isMobileDevice()) { // isMobileDevice() is from utils.js if (element.classList.contains('hidden')) { // Settings panel is NOW hidden (i.e., "collapsed") toggleButton.style.opacity = '0.3'; } else { // Settings panel is NOW visible (i.e., "open") toggleButton.style.opacity = ''; // Revert to default CSS opacity (effectively 1) } } else { // On non-mobile, ensure opacity always reverts to default CSS regardless of panel state toggleButton.style.opacity = ''; } } // Special case for filter slider button if (element.id === 'tweet-filter-container') { const filterToggle = document.getElementById('filter-toggle'); if (filterToggle) { if (!isCurrentlyHidden) { // We're closing the filter setTimeout(() => { filterToggle.style.display = 'block'; }, 500); // Match the CSS transition duration } else { filterToggle.style.display = 'none'; } } } } // --- Core UI Logic --- /** * Injects the UI elements from the HTML resource into the page. */ function injectUI() { //combined userscript has a const named MENU. If it exists, use it. let menuHTML; if (MENU) { menuHTML = MENU; } else { menuHTML = browserGet('menuHTML'); } if (!menuHTML) { console.error('Failed to load Menu.html resource!'); showStatus('Error: Could not load UI components.'); return null; } // Create a container to inject HTML const containerId = 'tweetfilter-root-container'; // Use the ID from the updated HTML let uiContainer = document.getElementById(containerId); if (uiContainer) { console.warn('UI container already exists. Skipping injection.'); return uiContainer; // Return existing container } uiContainer = document.createElement('div'); uiContainer.id = containerId; uiContainer.innerHTML = menuHTML; // Append the rest of the UI elements document.body.appendChild(uiContainer); console.log('TweetFilter UI Injected from HTML resource.'); // Set version number const versionInfo = uiContainer.querySelector('#version-info'); if (versionInfo) { versionInfo.textContent = `Twitter De-Sloppifier v${VERSION}`; } return uiContainer; // Return the newly created container } /** * Initializes all UI event listeners using event delegation. * @param {HTMLElement} uiContainer - The root container element for the UI. */ function initializeEventListeners(uiContainer) { if (!uiContainer) { console.error('UI Container not found for event listeners.'); return; } console.log('Wiring UI events...'); const settingsContainer = uiContainer.querySelector('#settings-container'); const filterContainer = uiContainer.querySelector('#tweet-filter-container'); const settingsToggleBtn = uiContainer.querySelector('#settings-toggle'); const filterToggleBtn = uiContainer.querySelector('#filter-toggle'); // --- Delegated Event Listener for Clicks --- uiContainer.addEventListener('click', (event) => { const target = event.target; const actionElement = target.closest('[data-action]'); const action = actionElement?.dataset.action; const setting = target.dataset.setting; const paramName = target.closest('.parameter-row')?.dataset.paramName; const tab = target.dataset.tab; const toggleTargetId = target.closest('[data-toggle]')?.dataset.toggle; // Button Actions if (action) { switch (action) { case 'close-filter': toggleElementVisibility(filterContainer, filterToggleBtn, 'Filter Slider', 'Filter Slider'); break; case 'toggle-settings': case 'close-settings': toggleElementVisibility(settingsContainer, settingsToggleBtn, '<span style="font-size: 14px;">✕</span> Close', '<span style="font-size: 14px;">⚙️</span> Settings'); break; case 'save-api-key': saveApiKey(); break; case 'clear-cache': clearTweetRatingsAndRefreshUI(); break; case 'reset-settings': resetSettings(isMobileDevice()); break; case 'save-instructions': saveInstructions(); break; case 'add-handle': addHandleFromInput(); break; case 'clear-instructions-history': clearInstructionsHistory(); break; case 'export-cache': exportCacheToJson(); break; } } // Handle List Removal (delegated) if (target.classList.contains('remove-handle')) { const handleItem = target.closest('.handle-item'); const handleTextElement = handleItem?.querySelector('.handle-text'); if (handleTextElement) { const handle = handleTextElement.textContent.substring(1); // Remove '@' removeHandleFromBlacklist(handle); } } // Tab Switching if (tab) { switchTab(tab); } // Advanced Options Toggle if (toggleTargetId) { toggleAdvancedOptions(toggleTargetId); } }); // --- Delegated Event Listener for Input/Change --- uiContainer.addEventListener('input', (event) => { const target = event.target; const setting = target.dataset.setting; const paramName = target.closest('.parameter-row')?.dataset.paramName; // Settings Inputs / Toggles if (setting) { handleSettingChange(target, setting); } // Parameter Controls (Sliders/Number Inputs) if (paramName) { handleParameterChange(target, paramName); } // Filter Slider if (target.id === 'tweet-filter-slider') { handleFilterSliderChange(target); } if (target.id === 'tweet-filter-value') { handleFilterValueInput(target); } }); uiContainer.addEventListener('change', (event) => { const target = event.target; const setting = target.dataset.setting; // Settings Inputs / Toggles (for selects like sort order) if (setting === 'modelSortOrder') { handleSettingChange(target, setting); fetchAvailableModels(); // Refresh models on sort change } // Settings Checkbox toggle (need change event for checkboxes) if (setting === 'enableImageDescriptions') { handleSettingChange(target, setting); } }); // --- Direct Event Listeners (Less common cases) --- // Filter Toggle Button if (filterToggleBtn) { filterToggleBtn.onclick = () => { if (filterContainer) { filterContainer.style.display = 'flex'; // Force a reflow filterContainer.offsetHeight; filterContainer.classList.remove('hidden'); } filterToggleBtn.style.display = 'none'; }; } // Close custom selects when clicking outside document.addEventListener('click', closeAllSelectBoxes); // Add handlers for new controls const showFreeModelsCheckbox = uiContainer.querySelector('#show-free-models'); if (showFreeModelsCheckbox) { showFreeModelsCheckbox.addEventListener('change', function () { showFreeModels = this.checked; browserSet('showFreeModels', showFreeModels); refreshModelsUI(); }); } const sortDirectionBtn = uiContainer.querySelector('#sort-direction'); if (sortDirectionBtn) { sortDirectionBtn.addEventListener('click', function () { const currentDirection = browserGet('sortDirection', 'default'); const newDirection = currentDirection === 'default' ? 'reverse' : 'default'; browserSet('sortDirection', newDirection); this.dataset.value = newDirection; refreshModelsUI(); }); } const modelSortSelect = uiContainer.querySelector('#model-sort-order'); if (modelSortSelect) { modelSortSelect.addEventListener('change', function () { browserSet('modelSortOrder', this.value); // Set default direction for latency and age if (this.value === 'latency-low-to-high') { browserSet('sortDirection', 'default'); // Show lowest latency first } else if (this.value === '') { // Age browserSet('sortDirection', 'default'); // Show newest first } refreshModelsUI(); }); } const providerSortSelect = uiContainer.querySelector('#provider-sort'); if (providerSortSelect) { providerSortSelect.addEventListener('change', function () { providerSort = this.value; browserSet('providerSort', providerSort); }); } console.log('UI events wired.'); } // --- Event Handlers --- /** Saves the API key from the input field. */ function saveApiKey() { const apiKeyInput = document.getElementById('openrouter-api-key'); const apiKey = apiKeyInput.value.trim(); let previousAPIKey = browserGet('openrouter-api-key', '').length > 0 ? true : false; if (apiKey) { if (!previousAPIKey) { resetSettings(true); //jank hack to get the UI defaults to load correctly } browserSet('openrouter-api-key', apiKey); showStatus('API key saved successfully!'); fetchAvailableModels(); // Refresh model list //refresh the website location.reload(); } else { showStatus('Please enter a valid API key'); } } /** * Exports the current tweet cache to a JSON file. */ function exportCacheToJson() { if (!tweetCache) { showStatus('Error: Tweet cache not found.', 'error'); return; } try { const cacheData = tweetCache.cache; // Access the raw cache object if (!cacheData || Object.keys(cacheData).length === 0) { showStatus('Cache is empty. Nothing to export.', 'warning'); return; } const jsonString = JSON.stringify(cacheData, null, 2); // Pretty print JSON const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.setAttribute('href', url); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); link.setAttribute('download', `tweet-filter-cache-${timestamp}.json`); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); showStatus(`Cache exported successfully (${Object.keys(cacheData).length} items).`); } catch (error) { console.error('Error exporting cache:', error); showStatus('Error exporting cache. Check console for details.', 'error'); } } /** Clears tweet ratings and updates the relevant UI parts. */ function clearTweetRatingsAndRefreshUI() { if (isMobileDevice() || confirm('Are you sure you want to clear all cached tweet ratings?')) { // Clear all ratings tweetCache.clear(true); // Reset pending requests counter pendingRequests = 0; // Clear thread relationships cache if (window.threadRelationships) { window.threadRelationships = {}; browserSet('threadRelationships', '{}'); console.log('Cleared thread relationships cache'); } showStatus('All cached ratings and thread relationships cleared!'); console.log('Cleared all tweet ratings and thread relationships'); // Reset all tweet elements to unrated state and reprocess them if (observedTargetNode) { observedTargetNode.querySelectorAll('article[data-testid="tweet"]').forEach(tweet => { tweet.removeAttribute('data-sloppiness-score'); tweet.removeAttribute('data-rating-status'); tweet.removeAttribute('data-rating-description'); tweet.removeAttribute('data-cached-rating'); const indicator = tweet.querySelector('.score-indicator'); if (indicator) { indicator.remove(); } // Remove from processed set and schedule reprocessing const tweetId = getTweetID(tweet); // Get ID *before* potential errors if (tweetId) { // Ensure we have an ID processedTweets.delete(tweetId); // Explicitly destroy the old ScoreIndicator instance from the registry const indicatorInstance = ScoreIndicatorRegistry.get(tweetId); if (indicatorInstance) { indicatorInstance.destroy(); } scheduleTweetProcessing(tweet); // Now schedule processing } }); } // Reset thread mapping on any conversation containers document.querySelectorAll('div[aria-label="Timeline: Conversation"], div[aria-label^="Timeline: Conversation"]').forEach(conversation => { delete conversation.dataset.threadMapping; delete conversation.dataset.threadMappedAt; delete conversation.dataset.threadMappingInProgress; delete conversation.dataset.threadHist; delete conversation.dataset.threadMediaUrls; }); } } /** Adds a handle from the input field to the blacklist. */ function addHandleFromInput() { const handleInput = document.getElementById('handle-input'); const handle = handleInput.value.trim(); if (handle) { addHandleToBlacklist(handle); handleInput.value = ''; } } /** * Handles changes to general setting inputs/toggles. * @param {HTMLElement} target - The input/toggle element that changed. * @param {string} settingName - The name of the setting (from data-setting). */ function handleSettingChange(target, settingName) { let value; if (target.type === 'checkbox') { value = target.checked; } else { value = target.value; } // Update global variable if it exists if (window[settingName] !== undefined) { window[settingName] = value; } // Save to GM storage browserSet(settingName, value); // Special UI updates for specific settings if (settingName === 'enableImageDescriptions') { const imageModelContainer = document.getElementById('image-model-container'); if (imageModelContainer) { imageModelContainer.style.display = value ? 'block' : 'none'; } showStatus('Image descriptions ' + (value ? 'enabled' : 'disabled')); } if (settingName === 'enableWebSearch') { showStatus('Web search for rating model ' + (value ? 'enabled' : 'disabled')); } if (settingName === 'enableAutoRating') { showStatus('Auto-rating ' + (value ? 'enabled' : 'disabled')); } } /** * Handles changes to parameter control sliders/number inputs. * @param {HTMLElement} target - The slider or number input element. * @param {string} paramName - The name of the parameter (from data-param-name). */ function handleParameterChange(target, paramName) { const row = target.closest('.parameter-row'); if (!row) return; const slider = row.querySelector('.parameter-slider'); const valueInput = row.querySelector('.parameter-value'); const min = parseFloat(slider.min); const max = parseFloat(slider.max); let newValue = parseFloat(target.value); // Clamp value if it's from the number input if (target.type === 'number' && !isNaN(newValue)) { newValue = Math.max(min, Math.min(max, newValue)); } // Update both slider and input if (slider && valueInput) { slider.value = newValue; valueInput.value = newValue; } // Update global variable if (window[paramName] !== undefined) { window[paramName] = newValue; } // Save to GM storage browserSet(paramName, newValue); } /** * Handles changes to the main filter slider. * @param {HTMLElement} slider - The filter slider element. */ function handleFilterSliderChange(slider) { const valueInput = document.getElementById('tweet-filter-value'); currentFilterThreshold = parseInt(slider.value, 10); if (valueInput) { valueInput.value = currentFilterThreshold.toString(); } // Update the gradient position based on the slider value const percentage = (currentFilterThreshold / 10) * 100; slider.style.setProperty('--slider-percent', `${percentage}%`); browserSet('filterThreshold', currentFilterThreshold); applyFilteringToAll(); } /** * Handles changes to the numeric input for filter threshold. * @param {HTMLElement} input - The numeric input element. */ function handleFilterValueInput(input) { let value = parseInt(input.value, 10); // Clamp value between 0 and 10 value = Math.max(0, Math.min(10, value)); input.value = value.toString(); // Update input to clamped value const slider = document.getElementById('tweet-filter-slider'); if (slider) { slider.value = value.toString(); // Update the gradient position const percentage = (value / 10) * 100; slider.style.setProperty('--slider-percent', `${percentage}%`); } currentFilterThreshold = value; browserSet('filterThreshold', currentFilterThreshold); applyFilteringToAll(); } /** * Switches the active tab in the settings panel. * @param {string} tabName - The name of the tab to activate (from data-tab). */ function switchTab(tabName) { const settingsContent = document.querySelector('#settings-container .settings-content'); if (!settingsContent) return; const tabs = settingsContent.querySelectorAll('.tab-content'); const buttons = settingsContent.querySelectorAll('.tab-navigation .tab-button'); tabs.forEach(tab => tab.classList.remove('active')); buttons.forEach(btn => btn.classList.remove('active')); const tabToShow = settingsContent.querySelector(`#${tabName}-tab`); const buttonToActivate = settingsContent.querySelector(`.tab-navigation .tab-button[data-tab="${tabName}"]`); if (tabToShow) tabToShow.classList.add('active'); if (buttonToActivate) buttonToActivate.classList.add('active'); } /** * Toggles the visibility of advanced options sections. * @param {string} contentId - The ID of the content element to toggle. */ function toggleAdvancedOptions(contentId) { const content = document.getElementById(contentId); const toggle = document.querySelector(`[data-toggle="${contentId}"]`); if (!content || !toggle) return; const icon = toggle.querySelector('.advanced-toggle-icon'); const isExpanded = content.classList.toggle('expanded'); if (icon) { icon.classList.toggle('expanded', isExpanded); } // Adjust max-height for smooth animation if (isExpanded) { content.style.maxHeight = content.scrollHeight + 'px'; } else { content.style.maxHeight = '0'; } } /** * Refreshes the entire settings UI to reflect current settings. */ function refreshSettingsUI() { // Update general settings inputs/toggles document.querySelectorAll('[data-setting]').forEach(input => { const settingName = input.dataset.setting; const value = browserGet(settingName, window[settingName]); // Get saved or default value if (input.type === 'checkbox') { input.checked = value; // Trigger change handler for side effects (like hiding/showing image model section) handleSettingChange(input, settingName); } else { input.value = value; } }); // Update parameter controls (sliders/number inputs) document.querySelectorAll('.parameter-row[data-param-name]').forEach(row => { const paramName = row.dataset.paramName; const slider = row.querySelector('.parameter-slider'); const valueInput = row.querySelector('.parameter-value'); const value = browserGet(paramName, window[paramName]); if (slider) slider.value = value; if (valueInput) valueInput.value = value; }); // Update filter slider and value input const filterSlider = document.getElementById('tweet-filter-slider'); const filterValueInput = document.getElementById('tweet-filter-value'); const currentThreshold = browserGet('filterThreshold', '5'); if (filterSlider && filterValueInput) { filterSlider.value = currentThreshold; filterValueInput.value = currentThreshold; // Initialize the gradient position const percentage = (parseInt(currentThreshold, 10) / 10) * 100; filterSlider.style.setProperty('--slider-percent', `${percentage}%`); } // Refresh dynamically populated lists/dropdowns refreshHandleList(document.getElementById('handle-list')); refreshModelsUI(); // Refreshes model dropdowns // Set initial state for advanced sections (collapsed by default unless CSS specifies otherwise) document.querySelectorAll('.advanced-content').forEach(content => { if (!content.classList.contains('expanded')) { content.style.maxHeight = '0'; } }); document.querySelectorAll('.advanced-toggle-icon.expanded').forEach(icon => { // Ensure icon matches state if CSS defaults to expanded if (!icon.closest('.advanced-toggle')?.nextElementSibling?.classList.contains('expanded')) { icon.classList.remove('expanded'); } }); // Refresh instructions history refreshInstructionsHistory(); } /** * Refreshes the handle list UI. * @param {HTMLElement} listElement - The list element to refresh. */ function refreshHandleList(listElement) { if (!listElement) return; listElement.innerHTML = ''; // Clear existing list if (blacklistedHandles.length === 0) { const emptyMsg = document.createElement('div'); emptyMsg.style.cssText = 'padding: 8px; opacity: 0.7; font-style: italic;'; emptyMsg.textContent = 'No handles added yet'; listElement.appendChild(emptyMsg); return; } blacklistedHandles.forEach(handle => { const item = document.createElement('div'); item.className = 'handle-item'; const handleText = document.createElement('div'); handleText.className = 'handle-text'; handleText.textContent = '@' + handle; item.appendChild(handleText); const removeBtn = document.createElement('button'); removeBtn.className = 'remove-handle'; removeBtn.textContent = '×'; removeBtn.title = 'Remove from list'; // removeBtn listener is handled by delegation in initializeEventListeners item.appendChild(removeBtn); listElement.appendChild(item); }); } /** * Updates the model selection dropdowns based on availableModels. */ function refreshModelsUI() { const modelSelectContainer = document.getElementById('model-select-container'); const imageModelSelectContainer = document.getElementById('image-model-select-container'); // Filter and sort models listedModels = [...availableModels]; // Filter free models if needed if (!showFreeModels) { listedModels = listedModels.filter(model => !model.slug.endsWith(':free')); } // Sort models based on current sort order and direction const sortDirection = browserGet('sortDirection', 'default'); const sortOrder = browserGet('modelSortOrder', 'throughput-high-to-low'); // Update toggle button text based on sort order const toggleBtn = document.getElementById('sort-direction'); if (toggleBtn) { switch (sortOrder) { case 'latency-low-to-high': toggleBtn.textContent = sortDirection === 'default' ? 'High-Low' : 'Low-High'; if (sortDirection === 'reverse') listedModels.reverse(); break; case '': // Age toggleBtn.textContent = sortDirection === 'default' ? 'New-Old' : 'Old-New'; if (sortDirection === 'reverse') listedModels.reverse(); break; case 'top-weekly': toggleBtn.textContent = sortDirection === 'default' ? 'Most Popular' : 'Least Popular'; if (sortDirection === 'reverse') listedModels.reverse(); break; default: toggleBtn.textContent = sortDirection === 'default' ? 'High-Low' : 'Low-High'; if (sortDirection === 'reverse') listedModels.reverse(); } } // Update main model selector if (modelSelectContainer) { modelSelectContainer.innerHTML = ''; createCustomSelect( modelSelectContainer, 'model-selector', listedModels.map(model => ({ value: model.endpoint?.model_variant_slug || model.id, label: formatModelLabel(model) })), selectedModel, (newValue) => { selectedModel = newValue; browserSet('selectedModel', selectedModel); showStatus('Rating model updated'); }, 'Search rating models...' ); } // Update image model selector if (imageModelSelectContainer) { const visionModels = listedModels.filter(model => model.input_modalities?.includes('image') || model.architecture?.input_modalities?.includes('image') || model.architecture?.modality?.includes('image') ); imageModelSelectContainer.innerHTML = ''; createCustomSelect( imageModelSelectContainer, 'image-model-selector', visionModels.map(model => ({ value: model.endpoint?.model_variant_slug || model.id, label: formatModelLabel(model) })), selectedImageModel, (newValue) => { selectedImageModel = newValue; browserSet('selectedImageModel', selectedImageModel); showStatus('Image model updated'); }, 'Search vision models...' ); } } /** * Formats a model object into a string for display in dropdowns. * @param {Object} model - The model object from the API. * @returns {string} A formatted label string. */ function formatModelLabel(model) { let label = model.endpoint?.model_variant_slug || model.id || model.name || 'Unknown Model'; let pricingInfo = ''; // Extract pricing const pricing = model.endpoint?.pricing || model.pricing; if (pricing) { const promptPrice = parseFloat(pricing.prompt); const completionPrice = parseFloat(pricing.completion); if (!isNaN(promptPrice)) { pricingInfo += ` - $${(promptPrice * 1e6).toFixed(4)}/mil. tok.-in`; if (!isNaN(completionPrice) && completionPrice !== promptPrice) { pricingInfo += ` $${(completionPrice * 1e6).toFixed(4)}/mil. tok.-out`; } } else if (!isNaN(completionPrice)) { // Handle case where only completion price is available (less common) pricingInfo += ` - $${(completionPrice * 1e6).toFixed(4)}/mil. tok.-out`; } } // Add vision icon const isVision = model.input_modalities?.includes('image') || model.architecture?.input_modalities?.includes('image') || model.architecture?.modality?.includes('image'); if (isVision) { label = '🖼️ ' + label; } return label + pricingInfo; } // --- Custom Select Dropdown Logic (largely unchanged, but included for completeness) --- /** * Creates a custom select dropdown with search functionality. * @param {HTMLElement} container - Container to append the custom select to. * @param {string} id - ID for the root custom-select div. * @param {Array<{value: string, label: string}>} options - Options for the dropdown. * @param {string} initialSelectedValue - Initially selected value. * @param {Function} onChange - Callback function when selection changes. * @param {string} searchPlaceholder - Placeholder text for the search input. */ function createCustomSelect(container, id, options, initialSelectedValue, onChange, searchPlaceholder) { let currentSelectedValue = initialSelectedValue; const customSelect = document.createElement('div'); customSelect.className = 'custom-select'; customSelect.id = id; const selectSelected = document.createElement('div'); selectSelected.className = 'select-selected'; const selectItems = document.createElement('div'); selectItems.className = 'select-items'; selectItems.style.display = 'none'; // Initially hidden const searchField = document.createElement('div'); searchField.className = 'search-field'; const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.className = 'search-input'; searchInput.placeholder = searchPlaceholder || 'Search...'; searchField.appendChild(searchInput); selectItems.appendChild(searchField); // Function to render options function renderOptions(filter = '') { // Clear previous options (excluding search field) while (selectItems.childNodes.length > 1) { selectItems.removeChild(selectItems.lastChild); } const filteredOptions = options.filter(opt => opt.label.toLowerCase().includes(filter.toLowerCase()) ); if (filteredOptions.length === 0) { const noResults = document.createElement('div'); noResults.textContent = 'No matches found'; noResults.style.cssText = 'opacity: 0.7; font-style: italic; padding: 10px; text-align: center; cursor: default;'; selectItems.appendChild(noResults); } filteredOptions.forEach(option => { const optionDiv = document.createElement('div'); optionDiv.textContent = option.label; optionDiv.dataset.value = option.value; if (option.value === currentSelectedValue) { optionDiv.classList.add('same-as-selected'); } optionDiv.addEventListener('click', (e) => { e.stopPropagation(); // Prevent closing immediately currentSelectedValue = option.value; selectSelected.textContent = option.label; selectItems.style.display = 'none'; selectSelected.classList.remove('select-arrow-active'); // Update classes for all items selectItems.querySelectorAll('div[data-value]').forEach(div => { div.classList.toggle('same-as-selected', div.dataset.value === currentSelectedValue); }); onChange(currentSelectedValue); }); selectItems.appendChild(optionDiv); }); } // Set initial display text const initialOption = options.find(opt => opt.value === currentSelectedValue); selectSelected.textContent = initialOption ? initialOption.label : 'Select an option'; customSelect.appendChild(selectSelected); customSelect.appendChild(selectItems); container.appendChild(customSelect); // Initial rendering renderOptions(); // Event listeners searchInput.addEventListener('input', () => renderOptions(searchInput.value)); searchInput.addEventListener('click', e => e.stopPropagation()); // Prevent closing selectSelected.addEventListener('click', (e) => { e.stopPropagation(); closeAllSelectBoxes(customSelect); // Close others const isHidden = selectItems.style.display === 'none'; selectItems.style.display = isHidden ? 'block' : 'none'; selectSelected.classList.toggle('select-arrow-active', isHidden); if (isHidden) { searchInput.focus(); searchInput.select(); // Select text for easy replacement renderOptions(searchInput.value); // Re-render in case options changed AND filter by current search term } }); } /** Closes all custom select dropdowns except the one passed in. */ function closeAllSelectBoxes(exceptThisOne = null) { document.querySelectorAll('.custom-select').forEach(select => { if (select === exceptThisOne) return; const items = select.querySelector('.select-items'); const selected = select.querySelector('.select-selected'); if (items) items.style.display = 'none'; if (selected) selected.classList.remove('select-arrow-active'); }); } /** * Resets all configurable settings to their default values. */ function resetSettings(noconfirm = false) { if (noconfirm || confirm('Are you sure you want to reset all settings to their default values? This will not clear your cached ratings, blacklisted handles, or instruction history.')) { tweetCache.clear(); // Define defaults (should match config.js ideally) const defaults = { selectedModel: 'openai/gpt-4.1-nano', selectedImageModel: 'openai/gpt-4.1-nano', enableImageDescriptions: false, enableStreaming: true, enableWebSearch: false, enableAutoRating: true, modelTemperature: 0.5, modelTopP: 0.9, imageModelTemperature: 0.5, imageModelTopP: 0.9, maxTokens: 0, filterThreshold: 5, userDefinedInstructions: 'Rate the tweet on a scale from 1 to 10 based on its clarity, insight, creativity, and overall quality.', modelSortOrder: 'throughput-high-to-low', sortDirection: 'default' }; // Apply defaults for (const key in defaults) { if (window[key] !== undefined) { window[key] = defaults[key]; } browserSet(key, defaults[key]); } refreshSettingsUI(); fetchAvailableModels(); showStatus('Settings reset to defaults'); } } // --- Blacklist/Whitelist Logic --- /** * Adds a handle to the blacklist, saves, and refreshes the UI. * @param {string} handle - The Twitter handle to add (with or without @). */ function addHandleToBlacklist(handle) { handle = handle.trim().replace(/^@/, ''); // Clean handle if (handle === '' || blacklistedHandles.includes(handle)) { showStatus(handle === '' ? 'Handle cannot be empty.' : `@${handle} is already on the list.`); return; } blacklistedHandles.push(handle); browserSet('blacklistedHandles', blacklistedHandles.join('\n')); refreshHandleList(document.getElementById('handle-list')); showStatus(`Added @${handle} to auto-rate list.`); } /** * Removes a handle from the blacklist, saves, and refreshes the UI. * @param {string} handle - The Twitter handle to remove (without @). */ function removeHandleFromBlacklist(handle) { const index = blacklistedHandles.indexOf(handle); if (index > -1) { blacklistedHandles.splice(index, 1); browserSet('blacklistedHandles', blacklistedHandles.join('\n')); refreshHandleList(document.getElementById('handle-list')); showStatus(`Removed @${handle} from auto-rate list.`); } else console.warn(`Attempted to remove non-existent handle: ${handle}`); } // --- Initialization --- /** * Main initialization function for the UI module. */ function initialiseUI() { const uiContainer = injectUI(); if (!uiContainer) return; initializeEventListeners(uiContainer); refreshSettingsUI(); fetchAvailableModels(); // Initialize the floating cache stats badge initializeFloatingCacheStats(); setInterval(updateCacheStatsUI, 3000); // Initialize tracking object for streaming requests if it doesn't exist if (!window.activeStreamingRequests) window.activeStreamingRequests = {}; } /** * Initializes event listeners and functionality for the floating cache stats badge. * This provides real-time feedback when tweets are rated and cached, * even when the settings panel is not open. */ function initializeFloatingCacheStats() { const statsBadge = document.getElementById('tweet-filter-stats-badge'); if (!statsBadge) return; // Add tooltip functionality statsBadge.title = 'Click to open settings'; // Add click event to open settings statsBadge.addEventListener('click', () => { const settingsToggle = document.getElementById('settings-toggle'); if (settingsToggle) { settingsToggle.click(); } }); // Auto-hide after 5 seconds of inactivity let fadeTimeout; const resetFadeTimeout = () => { clearTimeout(fadeTimeout); statsBadge.style.opacity = '1'; fadeTimeout = setTimeout(() => { statsBadge.style.opacity = '0.3'; }, 5000); }; statsBadge.addEventListener('mouseenter', () => { statsBadge.style.opacity = '1'; clearTimeout(fadeTimeout); }); statsBadge.addEventListener('mouseleave', resetFadeTimeout); resetFadeTimeout(); updateCacheStatsUI(); } // ----- ratingEngine.js ----- //src/ratingEngine.js /** * Applies filtering to a single tweet by replacing its contents with a minimal placeholder. * Also updates the rating indicator. * @param {Element} tweetArticle - The tweet element. */ function filterSingleTweet(tweetArticle) { const cell = tweetArticle.closest('div[data-testid="cellInnerDiv"]'); if (!cell) { console.warn("Couldn't find cellInnerDiv for tweet"); return; } const handles = getUserHandles(tweetArticle); const authorHandle = handles.length > 0 ? handles[0] : ''; const isAuthorActuallyBlacklisted = authorHandle && isUserBlacklisted(authorHandle); // Always store tweet data in dataset regardless of filtering const tweetText = getTweetText(tweetArticle) || ''; const mediaUrls = extractMediaLinksSync(tweetArticle); const tid = getTweetID(tweetArticle); cell.dataset.tweetText = tweetText; cell.dataset.authorHandle = authorHandle; cell.dataset.mediaUrls = JSON.stringify(mediaUrls); cell.dataset.tweetId = tid; const cacheUpdateData = { authorHandle: authorHandle, // Ensure authorHandle is cached for fallback individualTweetText: tweetText, individualMediaUrls: mediaUrls, // Use the synchronously extracted mediaUrls timestamp: Date.now() // Update timestamp to reflect new data }; tweetCache.set(tid, cacheUpdateData, false); // Use debounced save if (authorHandle && adAuthorCache.has(authorHandle)) { const tweetId = getTweetID(tweetArticle); if (tweetId) { ScoreIndicatorRegistry.get(tweetId)?.destroy(); } cell.innerHTML = ''; cell.dataset.filtered = 'true'; cell.dataset.isAd = 'true'; return; } const score = parseInt(tweetArticle.dataset.sloppinessScore || '9', 10); const tweetId = getTweetID(tweetArticle); const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle); indicatorInstance?.ensureIndicatorAttached(); const currentFilterThreshold = parseInt(browserGet('filterThreshold', '1')); const ratingStatus = tweetArticle.dataset.ratingStatus; if (indicatorInstance) { indicatorInstance.isAuthorBlacklisted = isAuthorActuallyBlacklisted; } if (isAuthorActuallyBlacklisted) { delete cell.dataset.filtered; cell.dataset.authorBlacklisted = 'true'; if (indicatorInstance) { indicatorInstance._updateIndicatorUI(); } } else { delete cell.dataset.authorBlacklisted; if (ratingStatus === 'pending' || ratingStatus === 'streaming') { delete cell.dataset.filtered; } else if (isNaN(score) || score < currentFilterThreshold) { const existingInstanceToDestroy = ScoreIndicatorRegistry.get(tweetId, tweetArticle); if (existingInstanceToDestroy) { existingInstanceToDestroy.destroy(); } cell.innerHTML = ''; cell.dataset.filtered = 'true'; } else { delete cell.dataset.filtered; if (indicatorInstance) { indicatorInstance._updateIndicatorUI(); } } } } /** * Applies a cached rating (if available) to a tweet article. * Also sets the rating status to 'rated' and updates the indicator. * @param {Element} tweetArticle - The tweet element. * @returns {boolean} True if a cached rating was applied. */ async function applyTweetCachedRating(tweetArticle) { const tweetId = getTweetID(tweetArticle); const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; // Check cache for rating const cachedRating = tweetCache.get(tweetId); if (cachedRating) { // Skip incomplete streaming entries that don't have a score yet if (cachedRating.streaming === true && (cachedRating.score === undefined || cachedRating.score === null)) { // console.log(`Skipping incomplete streaming cache for ${tweetId}`); return false; } // Ensure the score exists before applying it if (cachedRating.score !== undefined && cachedRating.score !== null) { // Update tweet article dataset properties - this is crucial for filterSingleTweet to work tweetArticle.dataset.sloppinessScore = cachedRating.score.toString(); tweetArticle.dataset.ratingStatus = cachedRating.fromStorage ? 'cached' : 'rated'; tweetArticle.dataset.ratingDescription = cachedRating.description || "not available"; tweetArticle.dataset.ratingReasoning = cachedRating.reasoning || ''; const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle); if (indicatorInstance) { indicatorInstance.rehydrateFromCache(cachedRating); } else { console.warn(`[applyTweetCachedRating] Could not get/create ScoreIndicator for ${tweetId} to apply cached rating.`); return false; // Cannot apply if indicator doesn't exist } filterSingleTweet(tweetArticle); return true; } else if (!cachedRating.streaming) { // Invalid cache entry - missing score console.warn(`Invalid cache entry for tweet ${tweetId}: missing score`); tweetCache.delete(tweetId); return false; } } return false; } // ----- UI Helper Functions ----- /** * Checks if a given user handle is in the blacklist. * @param {string} handle - The Twitter handle. * @returns {boolean} True if blacklisted, false otherwise. */ function isUserBlacklisted(handle) { if (!handle) return false; handle = handle.toLowerCase().trim(); return blacklistedHandles.some(h => h.toLowerCase().trim() === handle); } // Add near the top with other globals const VALID_FINAL_STATES = ['rated', 'cached', 'blacklisted', 'manual']; const VALID_INTERIM_STATES = ['pending', 'streaming']; // Add near other global variables const getFullContextPromises = new Map(); function isValidFinalState(status) { return VALID_FINAL_STATES.includes(status); } function isValidInterimState(status) { return VALID_INTERIM_STATES.includes(status); } async function delayedProcessTweet(tweetArticle, tweetId, authorHandle) { let processingSuccessful = false; try { const apiKey = browserGet('openrouter-api-key', ''); if (!apiKey) { // Just set a default state and stop - no point retrying without an API key tweetArticle.dataset.ratingStatus = 'error'; tweetArticle.dataset.ratingDescription = "No API key"; ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({ status: 'error', score: 9, description: "No API key", questions: [], lastAnswer: "" }); filterSingleTweet(tweetArticle); // Don't remove from processedTweets - we don't want to reprocess until they add a key and refresh return; } // Check if this is from a known ad author if (authorHandle && adAuthorCache.has(authorHandle)) { tweetArticle.dataset.ratingStatus = 'rated'; tweetArticle.dataset.ratingDescription = "Advertisement"; tweetArticle.dataset.sloppinessScore = '0'; ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({ status: 'rated', score: 0, description: "Advertisement from known ad author", questions: [], lastAnswer: "" }); filterSingleTweet(tweetArticle); processingSuccessful = true; return; } // Check if this is an ad if (isAd(tweetArticle)) { if (authorHandle) { adAuthorCache.add(authorHandle); } tweetArticle.dataset.ratingStatus = 'rated'; tweetArticle.dataset.ratingDescription = "Advertisement"; tweetArticle.dataset.sloppinessScore = '0'; ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({ status: 'rated', score: 0, description: "Advertisement", questions: [], lastAnswer: "" }); filterSingleTweet(tweetArticle); processingSuccessful = true; return; } let score = 5; // Default score if rating fails let description = ""; let reasoning = ""; let questions = []; // Initialize questions let lastAnswer = ""; // Initialize lastAnswer try { const cachedRating = tweetCache.get(tweetId); if (cachedRating) { // Handle incomplete streaming entries specifically if (cachedRating.streaming === true && (cachedRating.score === undefined || cachedRating.score === null)) { console.log(`Tweet ${tweetId} has incomplete streaming cache entry, continuing with processing`); } else if (!cachedRating.streaming && (cachedRating.score === undefined || cachedRating.score === null)) { // Invalid cache entry (non-streaming but missing score), delete it console.warn(`Invalid cache entry for tweet ${tweetId}, removing from cache`, cachedRating); tweetCache.delete(tweetId); } } const fullContextWithImageDescription = await getFullContext(tweetArticle, tweetId, apiKey); if (!fullContextWithImageDescription) { throw new Error("Failed to get tweet context"); } let mediaURLs = []; // Add thread relationship context only if is conversation if (document.querySelector('div[aria-label="Timeline: Conversation"]')) { const replyInfo = getTweetReplyInfo(tweetId); if (replyInfo && replyInfo.replyTo) { // Add thread context to cache entry if we process this tweet if (!tweetCache.has(tweetId)) { tweetCache.set(tweetId, {}); } if (!tweetCache.get(tweetId).threadContext) { tweetCache.get(tweetId).threadContext = { replyTo: replyInfo.to, replyToId: replyInfo.replyTo, isRoot: false }; } } } // Get all media URLs from any section in one go const mediaMatches1 = fullContextWithImageDescription.matchAll(/(?:\[MEDIA_URLS\]:\s*\n)(.*?)(?:\n|$)/g); const mediaMatches2 = fullContextWithImageDescription.matchAll(/(?:\[QUOTED_TWEET_MEDIA_URLS\]:\s*\n)(.*?)(?:\n|$)/g); for (const match of mediaMatches1) { if (match[1]) { mediaURLs.push(...match[1].split(', ').filter(url => url.trim())); } } for (const match of mediaMatches2) { if (match[1]) { mediaURLs.push(...match[1].split(', ').filter(url => url.trim())); } } // Remove duplicates and empty URLs mediaURLs = [...new Set(mediaURLs.filter(url => url.trim()))]; // ---- Start of new check for media extraction failure ---- const hasPotentialImageContainers = tweetArticle.querySelector('div[data-testid="tweetPhoto"], div[data-testid="videoPlayer"]'); // Check for photo or video containers const imageDescriptionsEnabled = browserGet('enableImageDescriptions', false); if (hasPotentialImageContainers && mediaURLs.length === 0 && (imageDescriptionsEnabled || modelSupportsImages(selectedModel))) { // Heuristic: If image/video containers are in the DOM, but we extracted no media URLs, // and either image descriptions are on OR the model supports images (meaning URLs are important), // then it's likely an extraction failure. const warningMessage = `Tweet ${tweetId}: Potential media containers found in DOM, but no media URLs were extracted by getFullContext. Forcing error for retry.`; console.warn(warningMessage); // Throw an error that will be caught by the generic catch block below, // which will set the status to 'error' and trigger the retry mechanism. throw new Error("Media URLs not extracted despite presence of media containers."); } // ---- End of new check ---- // --- API Call or Fallback --- if (fullContextWithImageDescription) { try { // Check if there's already a complete entry in the cache before calling the API // This handles cases where cache appeared/completed *after* scheduling const currentCache = tweetCache.get(tweetId); // Re-fetch fresh cache state const isCached = currentCache && !currentCache.streaming && currentCache.score !== undefined && currentCache.score !== null; if (isCached) { // Use cached data instead of calling API score = currentCache.score; description = currentCache.description || ""; reasoning = currentCache.reasoning || ""; questions = currentCache.questions || []; // Get questions from cache lastAnswer = currentCache.lastAnswer || ""; // Get answer from cache const mediaUrls = currentCache.mediaUrls || []; // Get mediaUrls from cache processingSuccessful = true; console.log(`Using valid cache entry found for ${tweetId} before API call.`); // Update UI using cached data ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({ status: currentCache.fromStorage ? 'cached' : 'rated', score: score, description: description, reasoning: reasoning, questions: questions, lastAnswer: lastAnswer, metadata: currentCache.metadata || null, mediaUrls: mediaUrls // Pass mediaUrls to indicator }); filterSingleTweet(tweetArticle); return; // Exit after using cache } // If not cached, proceed with API call // rateTweetWithOpenRouter now returns questions as well const rating = await rateTweetWithOpenRouter(fullContextWithImageDescription, tweetId, apiKey, mediaURLs, 3, tweetArticle, authorHandle); score = rating.score; description = rating.content; reasoning = rating.reasoning || ''; questions = rating.questions || []; // Get questions from API result lastAnswer = ""; // Reset lastAnswer on new rating // Determine status based on cache/error state let finalStatus = rating.error ? 'error' : 'rated'; if (!rating.error) { const cacheEntry = tweetCache.get(tweetId); if (cacheEntry && cacheEntry.fromStorage) { finalStatus = 'cached'; } else if (rating.cached) { finalStatus = 'cached'; } } // Update tweet dataset tweetArticle.dataset.ratingStatus = finalStatus; tweetArticle.dataset.ratingDescription = description || "not available"; tweetArticle.dataset.sloppinessScore = score?.toString() || ''; tweetArticle.dataset.ratingReasoning = reasoning; // Optionally store questions/answer in dataset if needed // tweetArticle.dataset.ratingQuestions = JSON.stringify(questions); // tweetArticle.dataset.ratingLastAnswer = lastAnswer; // Update UI via ScoreIndicator ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({ status: finalStatus, score: score, description: description, reasoning: reasoning, questions: questions, lastAnswer: lastAnswer, metadata: rating.data?.id ? { generationId: rating.data.id } : null, // Pass metadata mediaUrls: mediaURLs // Pass mediaUrls to indicator }); processingSuccessful = !rating.error; // Cache is already updated by rateTweetWithOpenRouter, no need to duplicate here // We rely on rateTweetWithOpenRouter (or its sub-functions) to set the cache correctly, // including score, description, reasoning, questions, lastAnswer, metadata ID etc. filterSingleTweet(tweetArticle); return; // Return after API call attempt } catch (apiError) { console.error(`API error processing tweet ${tweetId}:`, apiError); score = 5; // Fallback score on API error description = `API Error: ${apiError.message}`; reasoning = ''; questions = []; // Clear questions on error lastAnswer = ''; // Clear answer on error processingSuccessful = false; // Update UI to reflect API error state ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({ status: 'error', score: score, description: description, questions: [], lastAnswer: "" }); // Update cache error state const errorCacheEntry = tweetCache.get(tweetId) || {}; // Get existing const errorUpdate = { ...errorCacheEntry, // Preserve existing fields like fullContext score: score, // Fallback score description: description, // Error message reasoning: reasoning, questions: questions, lastAnswer: lastAnswer, streaming: false, // error: true, // Consider standardizing 'error' field in TweetCache if used extensively timestamp: Date.now() // Update timestamp }; tweetCache.set(tweetId, errorUpdate, true); // Original used immediate save, retain for errors. filterSingleTweet(tweetArticle); return; // Return after API error handling } } filterSingleTweet(tweetArticle); } catch (error) { console.error(`Generic error processing tweet ${tweetId}: ${error}`, error.stack); if (error.message === "Media URLs not extracted despite presence of media containers.") { if (tweetCache.has(tweetId)) { tweetCache.delete(tweetId); console.log(`[delayedProcessTweet] Deleted cache for ${tweetId} due to media extraction failure.`); } } // Ensure some error state is shown if processing fails unexpectedly ScoreIndicatorRegistry.get(tweetId, tweetArticle)?.update({ status: 'error', score: 5, description: "Error during processing: " + error.message, questions: [], lastAnswer: "" }); filterSingleTweet(tweetArticle); // Apply filtering even on generic error processingSuccessful = false; } finally { if (!processingSuccessful) { processedTweets.delete(tweetId); } } } catch (error) { console.error(`Error processing tweet ${tweetId}:`, error); const indicatorInstance = ScoreIndicatorRegistry.get(tweetId); if (indicatorInstance) { indicatorInstance.update({ status: 'error', score: 5, description: "Error during processing: " + error.message, questions: [], lastAnswer: "" }); } filterSingleTweet(tweetArticle); processingSuccessful = false; } finally { // Always clean up the processed state if we didn't succeed if (!processingSuccessful) { processedTweets.delete(tweetId); // Check if we need to retry const indicatorInstance = ScoreIndicatorRegistry.get(tweetId); if (indicatorInstance && !isValidFinalState(indicatorInstance.status)) { console.log(`Tweet ${tweetId} processing failed, will retry later`); setTimeout(() => { if (!isValidFinalState(ScoreIndicatorRegistry.get(tweetId)?.status)) { scheduleTweetProcessing(tweetArticle); } }, PROCESSING_DELAY_MS * 2); } } } } // Add near the top with other global variables const MAPPING_INCOMPLETE_TWEETS = new Set(); // Modify scheduleTweetProcessing to check for incomplete mapping async function scheduleTweetProcessing(tweetArticle, rateAnyway = false) { // First, ensure the tweet has a valid ID const tweetId = getTweetID(tweetArticle); if (!tweetId) { return; } // Check if there's already an active streaming request if (window.activeStreamingRequests && window.activeStreamingRequests[tweetId]) { console.log(`Tweet ${tweetId} has an active streaming request, skipping processing`); return; } // Get the author handle const handles = getUserHandles(tweetArticle); const authorHandle = handles.length > 0 ? handles[0] : ''; // Check if this is from a known ad author if (authorHandle && adAuthorCache.has(authorHandle)) { filterSingleTweet(tweetArticle); // This will hide it return; } // Check if this is an ad if (isAd(tweetArticle)) { if (authorHandle) { adAuthorCache.add(authorHandle); } filterSingleTweet(tweetArticle); // This will hide it return; } const existingInstance = ScoreIndicatorRegistry.get(tweetId); if (existingInstance) { existingInstance.ensureIndicatorAttached(); // If we have a valid final state, just filter and return if (isValidFinalState(existingInstance.status)) { filterSingleTweet(tweetArticle); return; } // If we're in a valid interim state and marked as processed, keep waiting if (isValidInterimState(existingInstance.status) && processedTweets.has(tweetId)) { filterSingleTweet(tweetArticle); return; } // If we get here, we either have an error state or invalid state // Remove from processed set to allow reprocessing processedTweets.delete(tweetId); } // Check if we're in a conversation view const conversation = document.querySelector('div[aria-label="Timeline: Conversation"]') || document.querySelector('div[aria-label^="Timeline: Conversation"]'); if (conversation) { // If we're in a conversation and mapping is not complete, mark this tweet for later processing if (!conversation.dataset.threadMapping) { console.log(`[scheduleTweetProcessing] Tweet ${tweetId} waiting for thread mapping`); MAPPING_INCOMPLETE_TWEETS.add(tweetId); const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle); if (indicatorInstance) { indicatorInstance.update({ status: 'pending', score: null, description: 'Waiting for thread context...', questions: [], lastAnswer: "" }); } return; } // If we have thread mapping, check if this tweet is in it try { const mapping = JSON.parse(conversation.dataset.threadMapping); const tweetMapping = mapping.find(m => m.tweetId === tweetId); if (!tweetMapping) { console.log(`[scheduleTweetProcessing] Tweet ${tweetId} not found in thread mapping, waiting`); MAPPING_INCOMPLETE_TWEETS.add(tweetId); const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle); if (indicatorInstance) { indicatorInstance.update({ status: 'pending', score: null, description: 'Waiting for thread context...', questions: [], lastAnswer: "" }); } return; } } catch (e) { console.error("Error parsing thread mapping:", e); } } // Check for a cached rating, but be careful with streaming cache entries if (tweetCache.has(tweetId)) { // Only apply cached rating if it has a valid score and isn't an incomplete streaming entry const isIncompleteStreaming = tweetCache.get(tweetId).streaming === true && (tweetCache.get(tweetId).score === undefined || tweetCache.get(tweetId).score === null); if (!isIncompleteStreaming) { const wasApplied = await applyTweetCachedRating(tweetArticle); if (wasApplied) { return; } } } // Skip if already being processed in this session if (processedTweets.has(tweetId)) { const instance = ScoreIndicatorRegistry.get(tweetId); if (instance) { instance.ensureIndicatorAttached(); if (instance.status === 'pending' || instance.status === 'streaming') { filterSingleTweet(tweetArticle); return; } } // If we get here, the tweet is marked as processed but doesn't have a valid state // Remove it from processed set to allow reprocessing processedTweets.delete(tweetId); } // Immediately mark as pending before scheduling actual processing const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle); if (indicatorInstance) { if (indicatorInstance.status !== 'blacklisted' && indicatorInstance.status !== 'cached' && indicatorInstance.status !== 'rated') { indicatorInstance.update({ status: 'pending', score: null, description: 'Rating scheduled...', questions: [], lastAnswer: "" }); } else { // If already in a final state, ensure it's attached and filtered indicatorInstance.ensureIndicatorAttached(); filterSingleTweet(tweetArticle); return; } } else { console.error(`Failed to get/create indicator instance for tweet ${tweetId} during scheduling.`); } // Add to processed set *after* successfully getting/creating instance if (!processedTweets.has(tweetId)) { processedTweets.add(tweetId); } // Now schedule the actual rating processing setTimeout(() => { try { // Check if auto-rating is enabled, unless we're forcing a manual rate if (!browserGet('enableAutoRating', true) && !rateAnyway) { // If auto-rating is disabled, set status to manual instead of processing const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle); if (indicatorInstance) { indicatorInstance.update({ status: 'manual', score: null, description: 'Click the Rate button to rate this tweet', reasoning: '', questions: [], lastAnswer: "" }); filterSingleTweet(tweetArticle); } return; } delayedProcessTweet(tweetArticle, tweetId, authorHandle); } catch (e) { console.error(`Error in delayed processing of tweet ${tweetId}:`, e); processedTweets.delete(tweetId); } }, PROCESSING_DELAY_MS); } // Add this near the beginning of the file with other global variables // Store reply relationships across sessions let threadRelationships = {}; const THREAD_CHECK_INTERVAL = 500; // Reduce from 2500ms to 500ms const SWEEP_INTERVAL = 500; // Check for unrated tweets twice as often const THREAD_MAPPING_TIMEOUT = 1000; // Reduce from 5000ms to 1000ms let threadMappingInProgress = false; // Add a memory-based flag for more reliable state tracking // Load thread relationships from storage on script initialization function loadThreadRelationships() { try { const savedRelationships = browserGet('threadRelationships', '{}'); threadRelationships = JSON.parse(savedRelationships); console.log(`Loaded ${Object.keys(threadRelationships).length} thread relationships`); } catch (e) { console.error('Error loading thread relationships:', e); threadRelationships = {}; } } // Save thread relationships to persistent storage function saveThreadRelationships() { try { // Limit size to prevent storage issues const relationshipCount = Object.keys(threadRelationships).length; if (relationshipCount > 1000) { // If over 1000, keep only the most recent 500 const entries = Object.entries(threadRelationships); // Sort by timestamp if available, otherwise keep newest entries by default key order entries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0)); const recent = entries.slice(0, 500); threadRelationships = Object.fromEntries(recent); } browserSet('threadRelationships', JSON.stringify(threadRelationships)); } catch (e) { console.error('Error saving thread relationships:', e); } } // Initialize thread relationships on load loadThreadRelationships(); // Add this function to build a complete chain of replies async function buildReplyChain(tweetId, maxDepth = 10) { if (!tweetId || maxDepth <= 0) return []; // Start with empty chain const chain = []; // Current tweet ID to process let currentId = tweetId; let depth = 0; // Traverse up the chain recursively while (currentId && depth < maxDepth) { const replyInfo = threadRelationships[currentId]; if (!replyInfo || !replyInfo.replyTo) break; // Add this link in the chain chain.push({ fromId: currentId, toId: replyInfo.replyTo, from: replyInfo.from, to: replyInfo.to }); // Move up the chain currentId = replyInfo.replyTo; depth++; } return chain; } /** * Extracts the full context of a tweet article and returns a formatted string. * * Schema: * [TWEET]: * @[the author of the tweet] * [the text of the tweet] * [MEDIA_DESCRIPTION]: * [IMAGE 1]: [description], [IMAGE 2]: [description], etc. * [QUOTED_TWEET]: * [the text of the quoted tweet] * [QUOTED_TWEET_MEDIA_DESCRIPTION]: * [IMAGE 1]: [description], [IMAGE 2]: [description], etc. * * @param {Element} tweetArticle - The tweet article element. * @param {string} tweetId - The tweet's ID. * @param {string} apiKey - API key used for getting image descriptions. * @returns {Promise<string>} - The full context string. */ async function getFullContext(tweetArticle, tweetId, apiKey) { if (getFullContextPromises.has(tweetId)) { // console.log(`[getFullContext] Waiting for existing promise for ${tweetId}`); return getFullContextPromises.get(tweetId); } const contextPromise = (async () => { try { // --- Original getFullContext logic starts here --- const handles = getUserHandles(tweetArticle); const userHandle = handles.length > 0 ? handles[0] : ''; const quotedHandle = handles.length > 1 ? handles[1] : ''; // --- Extract Main Tweet Content --- const mainText = getTweetText(tweetArticle); let allMediaLinks = await extractMediaLinks(tweetArticle); // --- Extract Quoted Tweet Content (if any) --- let quotedText = ""; let quotedMediaLinks = []; let quotedTweetId = null; const quoteContainer = tweetArticle.querySelector(QUOTE_CONTAINER_SELECTOR); if (quoteContainer) { const quotedLink = quoteContainer.querySelector('a[href*="/status/"]'); if (quotedLink) { const href = quotedLink.getAttribute('href'); const match = href.match(/\/status\/(\d+)/); if (match && match[1]) { quotedTweetId = match[1]; } } quotedText = getElementText(quoteContainer.querySelector(TWEET_TEXT_SELECTOR)) || ""; quotedMediaLinks = await extractMediaLinks(quoteContainer); } const conversation = document.querySelector('div[aria-label="Timeline: Conversation"]') || document.querySelector('div[aria-label^="Timeline: Conversation"]'); let threadMediaUrls = []; if (conversation && conversation.dataset.threadMapping && tweetCache.has(tweetId) && tweetCache.get(tweetId).threadContext?.threadMediaUrls) { threadMediaUrls = tweetCache.get(tweetId).threadContext.threadMediaUrls || []; } else if (conversation && conversation.dataset.threadMediaUrls) { try { const allMediaUrls = JSON.parse(conversation.dataset.threadMediaUrls); threadMediaUrls = Array.isArray(allMediaUrls) ? allMediaUrls : []; } catch (e) { console.error("Error parsing thread media URLs:", e); } } let allAvailableMediaLinks = [...(allMediaLinks || [])]; let mainMediaLinks = allAvailableMediaLinks.filter(link => !quotedMediaLinks.includes(link)); let engagementStats = ""; const engagementDiv = tweetArticle.querySelector('div[role="group"][aria-label$=" views"]'); if (engagementDiv) { engagementStats = engagementDiv.getAttribute('aria-label')?.trim() || ""; } let fullContextWithImageDescription = `[TWEET ${tweetId}] Author:@${userHandle}: ` + mainText; if (mainMediaLinks.length > 0) { if (browserGet('enableImageDescriptions', false)) { // Re-check enableImageDescriptions, as it might have changed let mainMediaLinksDescription = await getImageDescription(mainMediaLinks, apiKey, tweetId, userHandle); fullContextWithImageDescription += ` [MEDIA_DESCRIPTION]: ${mainMediaLinksDescription}`; } fullContextWithImageDescription += ` [MEDIA_URLS]: ${mainMediaLinks.join(", ")}`; } if (engagementStats) { fullContextWithImageDescription += ` [ENGAGEMENT_STATS]: ${engagementStats}`; } if (!isOriginalTweet(tweetArticle) && threadMediaUrls.length > 0) { const uniqueThreadMediaUrls = threadMediaUrls.filter(url => !mainMediaLinks.includes(url) && !quotedMediaLinks.includes(url)); if (uniqueThreadMediaUrls.length > 0) { fullContextWithImageDescription += ` [THREAD_MEDIA_URLS]: ${uniqueThreadMediaUrls.join(", ")}`; } } if (quotedText || quotedMediaLinks.length > 0) { fullContextWithImageDescription += ` [QUOTED_TWEET${quotedTweetId ? ' ' + quotedTweetId : ''}]: Author:@${quotedHandle}: ${quotedText}`; if (quotedMediaLinks.length > 0) { if (browserGet('enableImageDescriptions', false)) { // Re-check enableImageDescriptions let quotedMediaLinksDescription = await getImageDescription(quotedMediaLinks, apiKey, tweetId, userHandle); // tweetId and userHandle are from main tweet for context fullContextWithImageDescription += ` [QUOTED_TWEET_MEDIA_DESCRIPTION]: ${quotedMediaLinksDescription}`; } fullContextWithImageDescription += ` [QUOTED_TWEET_MEDIA_URLS]: ${quotedMediaLinks.join(", ")}`; } } // --- Thread/Reply Logic --- const conversationElement = document.querySelector('div[aria-label="Timeline: Conversation"], div[aria-label^="Timeline: Conversation"]'); if (conversationElement) { const replyChain = await buildReplyChain(tweetId); let threadHistoryIncluded = false; if (conversationElement.dataset.threadHist) { if (!isOriginalTweet(tweetArticle)) { // Prepend thread history from conversation dataset fullContextWithImageDescription = conversationElement.dataset.threadHist + `\n[REPLY]\n` + fullContextWithImageDescription; threadHistoryIncluded = true; } } if (replyChain.length > 0 && !threadHistoryIncluded) { let parentContextsString = ""; let previousParentAuthor = null; for (let i = replyChain.length - 1; i >= 0; i--) { // Iterate from top-most parent downwards const link = replyChain[i]; const parentId = link.toId; const parentUser = link.to || 'unknown'; let currentParentContent = null; const parentCacheEntry = tweetCache.get(parentId); // Prioritize individual text from cache to break recursion if (parentCacheEntry && parentCacheEntry.individualTweetText) { currentParentContent = `[TWEET ${parentId}]\n Author:@${parentCacheEntry.authorHandle || parentUser}:\n${parentCacheEntry.individualTweetText}`; if (parentCacheEntry.individualMediaUrls && parentCacheEntry.individualMediaUrls.length > 0) { currentParentContent += `\n[MEDIA_URLS]:\n${parentCacheEntry.individualMediaUrls.join(", ")}`; } } else { const parentArticleElement = Array.from(document.querySelectorAll(TWEET_ARTICLE_SELECTOR)) .find(el => getTweetID(el) === parentId); if (parentArticleElement) { const originalParentRelationship = threadRelationships[parentId]; delete threadRelationships[parentId]; try { currentParentContent = await getFullContext(parentArticleElement, parentId, apiKey); } finally { if (originalParentRelationship) { threadRelationships[parentId] = originalParentRelationship; } } } } if (previousParentAuthor) { parentContextsString += `\n[REPLY TO @${previousParentAuthor}]\n`; } if (currentParentContent) { // Safeguard: In case of runaway recursion, strip everything before the last [TWEET marker const lastTweetMarker = currentParentContent.lastIndexOf('[TWEET '); if (lastTweetMarker > 0) { currentParentContent = currentParentContent.substring(lastTweetMarker); } parentContextsString += currentParentContent; } else { parentContextsString += `[CONTEXT UNAVAILABLE FOR TWEET ${parentId} @${parentUser}]`; } previousParentAuthor = parentUser; } if (previousParentAuthor) { parentContextsString += `\n[REPLY TO @${previousParentAuthor}]\n`; } fullContextWithImageDescription = parentContextsString + fullContextWithImageDescription; } const replyInfo = getTweetReplyInfo(tweetId); if (replyInfo && replyInfo.to && !threadHistoryIncluded && replyChain.length === 0) { fullContextWithImageDescription = `[REPLY TO @${replyInfo.to}]\n` + fullContextWithImageDescription; } } // --- End of Thread/Reply Logic --- tweetArticle.dataset.fullContext = fullContextWithImageDescription; // Store/update fullContext in tweetCache const existingCacheEntryForCurrentTweet = tweetCache.get(tweetId) || {}; const updatedCacheEntry = { ...existingCacheEntryForCurrentTweet, // Preserve other fields fullContext: fullContextWithImageDescription, timestamp: existingCacheEntryForCurrentTweet.timestamp || Date.now() // Update or set timestamp }; // If it's a completely new entry, ensure 'score' isn't accidentally set to non-undefined // (it defaults to undefined in TweetCache if not provided, which is desired here) if (existingCacheEntryForCurrentTweet.score === undefined && updatedCacheEntry.score === null) { // This can happen if existingCacheEntryForCurrentTweet had score:null from a previous partial setup // We want it to be undefined if no actual score yet. updatedCacheEntry.score = undefined; } tweetCache.set(tweetId, updatedCacheEntry, false); // Use debounced save return fullContextWithImageDescription; // --- Original getFullContext logic ends here --- } finally { getFullContextPromises.delete(tweetId); } })(); getFullContextPromises.set(tweetId, contextPromise); return contextPromise; } /** * Applies filtering to all tweets currently in the observed container. */ function applyFilteringToAll() { if (!observedTargetNode) return; const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR); tweets.forEach(filterSingleTweet); } function ensureAllTweetsRated() { if (document.querySelector('div[aria-label="Timeline: Conversation"]') || !browserGet('enableAutoRating',true)) { //this breaks thread handling logic, handlethreads calls scheduleTweetProcessing return; } if (!observedTargetNode) return; const tweets = observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR); if (tweets.length > 0) { console.log(`Checking ${tweets.length} tweets to ensure all are rated...`); tweets.forEach(tweet => { const tweetId = getTweetID(tweet); if (!tweetId) return; const indicatorInstance = ScoreIndicatorRegistry.get(tweetId); const needsProcessing = !indicatorInstance || !indicatorInstance.status || indicatorInstance.status === 'error' || (!isValidFinalState(indicatorInstance.status) && !isValidInterimState(indicatorInstance.status)) || (processedTweets.has(tweetId) && !isValidFinalState(indicatorInstance.status) && !isValidInterimState(indicatorInstance.status)); if (needsProcessing) { if (processedTweets.has(tweetId)) { console.log(`Tweet ${tweetId} marked as processed but in invalid state: ${indicatorInstance?.status}`); processedTweets.delete(tweetId); } scheduleTweetProcessing(tweet); } else if (indicatorInstance && !isValidInterimState(indicatorInstance.status)) { filterSingleTweet(tweet); } }); } } async function handleThreads() { try { // Find the conversation timeline using a more specific selector let conversation = document.querySelector('div[aria-label="Timeline: Conversation"]'); if (!conversation) { conversation = document.querySelector('div[aria-label^="Timeline: Conversation"]'); } if (!conversation) return; // If mapping is already in progress by another call, skip if (threadMappingInProgress || conversation.dataset.threadMappingInProgress === "true") { // console.log("[handleThreads] Skipping, mapping already in progress."); return; } // Check if a mapping was completed very recently const lastMappedTimestamp = parseInt(conversation.dataset.threadMappedAt || '0', 10); const MAPPING_COOLDOWN_MS = 1000; // 1 second cooldown if (Date.now() - lastMappedTimestamp < MAPPING_COOLDOWN_MS) { // console.log(`[handleThreads] Skipping, last map was too recent (${Date.now() - lastMappedTimestamp}ms ago).`); return; } // Extract the tweet ID from the URL const match = location.pathname.match(/status\/(\d+)/); const pageTweetId = match ? match[1] : null; if (!pageTweetId) return; // Determine the actual root tweet ID by climbing persistent threadRelationships let rootTweetId = pageTweetId; while (threadRelationships[rootTweetId] && threadRelationships[rootTweetId].replyTo) { rootTweetId = threadRelationships[rootTweetId].replyTo; } // Run the mapping immediately await mapThreadStructure(conversation, rootTweetId); } catch (error) { console.error("Error in handleThreads:", error); threadMappingInProgress = false; } } // Modify mapThreadStructure to trigger processing of waiting tweets async function mapThreadStructure(conversation, localRootTweetId) { // If already in progress, don't start another one if (threadMappingInProgress || conversation.dataset.threadMappingInProgress) { return; } // Mark mapping in progress to prevent duplicate processing conversation.dataset.threadMappingInProgress = "true"; threadMappingInProgress = true; // Set memory-based flag try { // Use a timeout promise to prevent hanging const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Thread mapping timed out')), THREAD_MAPPING_TIMEOUT) ); // The actual mapping function const mapping = async () => { // Get the tweet ID from the URL const urlMatch = location.pathname.match(/status\/(\d+)/); const urlTweetId = urlMatch ? urlMatch[1] : null; //console.log("[mapThreadStructure] URL Tweet ID:", urlTweetId); // Process all visible tweets using the cellInnerDiv structure for improved mapping // Use a more specific selector to ensure we get ALL cells in the conversation let cellDivs = Array.from(conversation.querySelectorAll('div[data-testid="cellInnerDiv"]')); //console.log("[mapThreadStructure] Found cellDivs:", cellDivs.length); if (!cellDivs.length) { console.log("No cell divs found, thread mapping aborted"); delete conversation.dataset.threadMappingInProgress; threadMappingInProgress = false; return; } // Debug log each cell's position and tweet ID cellDivs.forEach((cell, idx) => { const tweetId = cell.dataset.tweetId; const authorHandle = cell.dataset.authorHandle; //console.log(`[mapThreadStructure] Cell ${idx}: TweetID=${tweetId}, Author=${authorHandle}, Y=${cell.style.transform}`); }); // Sort cells by their vertical position to ensure correct order cellDivs.sort((a, b) => { const aY = parseInt(a.style.transform.match(/translateY\((\d+)/)?.[1] || '0'); const bY = parseInt(b.style.transform.match(/translateY\((\d+)/)?.[1] || '0'); return aY - bY; }); // Debug log sorted positions cellDivs.forEach((cell, idx) => { const tweetId = cell.dataset.tweetId; const authorHandle = cell.dataset.authorHandle; //console.log(`[mapThreadStructure] Sorted Cell ${idx}: TweetID=${tweetId}, Author=${authorHandle}, Y=${cell.style.transform}`); }); let tweetCells = []; let processedCount = 0; let urlTweetCellIndex = -1; // Index in tweetCells array // First pass: collect all tweet data and identify separators for (let idx = 0; idx < cellDivs.length; idx++) { const cell = cellDivs[idx]; let tweetId, username, text, mediaLinks = [], quotedMediaLinks = []; let article = cell.querySelector('article[data-testid="tweet"]'); if (article) { // Try to get data from article first tweetId = getTweetID(article); if (!tweetId) { let tweetLink = article.querySelector('a[href*="/status/"]'); if (tweetLink) { let match = tweetLink.href.match(/status\/(\d+)/); if (match) tweetId = match[1]; } } const handles = getUserHandles(article); username = handles.length > 0 ? handles[0] : null; text = getTweetText(article).replace(/\n+/g, ' ⏎ '); mediaLinks = await extractMediaLinks(article); const quoteContainer = article.querySelector(QUOTE_CONTAINER_SELECTOR); if (quoteContainer) { quotedMediaLinks = await extractMediaLinks(quoteContainer); } } // Fallback to cell dataset if article data is insufficient if (!tweetId && cell.dataset.tweetId) { tweetId = cell.dataset.tweetId; } if (!username && cell.dataset.authorHandle) { username = cell.dataset.authorHandle; } if (!text && cell.dataset.tweetText) { text = cell.dataset.tweetText || ''; } if ((!mediaLinks || !mediaLinks.length) && cell.dataset.mediaUrls) { try { mediaLinks = JSON.parse(cell.dataset.mediaUrls); } catch (e) { //console.warn("[mapThreadStructure] Error parsing mediaUrls from dataset:", e, cell.dataset.mediaUrls); mediaLinks = []; } } // Classify as 'tweet' or 'separator' if (tweetId && username) { // Essential data for a tweet const currentCellItem = { type: 'tweet', tweetNode: article, username, tweetId, text, mediaLinks, quotedMediaLinks, cellIndex: idx, cellDiv: cell, index: processedCount // This index will be for actual tweets in tweetCells later }; tweetCells.push(currentCellItem); if (tweetId === urlTweetId) { //console.log(`[mapThreadStructure] Found URL tweet at cellDiv index ${idx}, tweetCells index ${tweetCells.length - 1}`); urlTweetCellIndex = tweetCells.length - 1; // Store index within tweetCells } processedCount++; // Increment only for tweets // Schedule processing for this tweet if not already processed if (article && !processedTweets.has(tweetId)) { scheduleTweetProcessing(article); } } else { tweetCells.push({ type: 'separator', cellDiv: cell, cellIndex: idx, }); //console.log(`[mapThreadStructure] Cell ${idx} classified as separator.`); } } // Debug log collected items (tweets and separators) //console.log("[mapThreadStructure] Collected items (tweets and separators):", tweetCells.map(t => ({ type: t.type, id: t.tweetId, user: t.username, cellIdx: t.cellIndex }))); //console.log("[mapThreadStructure] URL tweet cell index in tweetCells:", urlTweetCellIndex); const urlTweetObject = urlTweetCellIndex !== -1 ? tweetCells[urlTweetCellIndex] : null; let effectiveUrlTweetInfo = null; if (urlTweetObject) { effectiveUrlTweetInfo = { tweetId: urlTweetObject.tweetId, username: urlTweetObject.username }; //console.log("[mapThreadStructure] URL Tweet Object found in DOM:", effectiveUrlTweetInfo); } else if (urlTweetId) { // If not in DOM, try cache const cachedUrlTweet = tweetCache.get(urlTweetId); if (cachedUrlTweet && cachedUrlTweet.authorHandle) { effectiveUrlTweetInfo = { tweetId: urlTweetId, username: cachedUrlTweet.authorHandle }; //console.log("[mapThreadStructure] URL Tweet Object not in DOM, using cached info:", effectiveUrlTweetInfo); } else { // console.log(`[mapThreadStructure] URL Tweet Object for ${urlTweetId} not found in DOM and no sufficient cache (missing authorHandle).`); } } else { //console.log("[mapThreadStructure] No URL Tweet ID available to begin with."); } // Build reply structure only if we have actual tweets to process const actualTweets = tweetCells.filter(tc => tc.type === 'tweet'); if (actualTweets.length === 0) { console.log("No valid tweets found, thread mapping aborted"); delete conversation.dataset.threadMappingInProgress; threadMappingInProgress = false; return; } // Second pass: build the reply structure based on new logic for (let i = 0; i < tweetCells.length; ++i) { let currentItem = tweetCells[i]; if (currentItem.type === 'separator') { //console.log(`[mapThreadStructure] Skipping separator at index ${i}`); continue; } // currentItem is a tweet here //console.log(`[mapThreadStructure] Processing tweet ${currentItem.tweetId} at tweetCells index ${i}`); if (i === 0) { // First item in the list currentItem.replyTo = null; currentItem.replyToId = null; currentItem.isRoot = true; //console.log(`[mapThreadStructure] Tweet ${currentItem.tweetId} is root (first item).`); } else { const previousItem = tweetCells[i - 1]; if (previousItem.type === 'separator') { if (effectiveUrlTweetInfo && currentItem.tweetId !== effectiveUrlTweetInfo.tweetId) { currentItem.replyTo = effectiveUrlTweetInfo.username; currentItem.replyToId = effectiveUrlTweetInfo.tweetId; currentItem.isRoot = false; //console.log(`[mapThreadStructure] Tweet ${currentItem.tweetId} replies to URL tweet ${effectiveUrlTweetInfo.tweetId} (after separator).`); } else if (effectiveUrlTweetInfo && currentItem.tweetId === effectiveUrlTweetInfo.tweetId) { // Current tweet is the URL tweet AND it's after a separator. It becomes a root. currentItem.replyTo = null; currentItem.replyToId = null; currentItem.isRoot = true; // console.log(`[mapThreadStructure] Tweet ${currentItem.tweetId} (URL tweet ${effectiveUrlTweetInfo.tweetId}) is root (after separator).`); } else { // No URL tweet or current is URL tweet - becomes a root of a new segment. currentItem.replyTo = null; currentItem.replyToId = null; currentItem.isRoot = true; // console.log(`[mapThreadStructure] Tweet ${currentItem.tweetId} is root (after separator, no/is URL tweet or no effective URL tweet info).`); } } else if (previousItem.type === 'tweet') { currentItem.replyTo = previousItem.username; currentItem.replyToId = previousItem.tweetId; currentItem.isRoot = false; //console.log(`[mapThreadStructure] Tweet ${currentItem.tweetId} replies to previous tweet ${previousItem.tweetId}.`); } else { // Should not happen if previousItem is always defined and typed //console.warn(`[mapThreadStructure] Tweet ${currentItem.tweetId} has unexpected previous item type:`, previousItem); currentItem.replyTo = null; currentItem.replyToId = null; currentItem.isRoot = true; } } } // Create replyDocs from actual tweets const replyDocs = tweetCells .filter(tc => tc.type === 'tweet') .map(tw => ({ from: tw.username, tweetId: tw.tweetId, to: tw.replyTo, toId: tw.replyToId, isRoot: tw.isRoot === true, text: tw.text, mediaLinks: tw.mediaLinks || [], quotedMediaLinks: tw.quotedMediaLinks || [] })); // Debug log final mapping /*console.log("[mapThreadStructure] Final reply mapping:", replyDocs.map(d => ({ from: d.from, tweetId: d.tweetId, replyTo: d.to, replyToId: d.toId, isRoot: d.isRoot })));*/ // Store the thread mapping in a dataset attribute for debugging conversation.dataset.threadMapping = JSON.stringify(replyDocs); // Process any tweets that were waiting for mapping for (const waitingTweetId of MAPPING_INCOMPLETE_TWEETS) { const mappedTweet = replyDocs.find(doc => doc.tweetId === waitingTweetId); if (mappedTweet) { //console.log(`[mapThreadStructure] Processing previously waiting tweet ${waitingTweetId}`); const tweetArticle = tweetCells.find(tc => tc.tweetId === waitingTweetId)?.tweetNode; if (tweetArticle) { processedTweets.delete(waitingTweetId); scheduleTweetProcessing(tweetArticle); } } } MAPPING_INCOMPLETE_TWEETS.clear(); // Update the global thread relationships const timestamp = Date.now(); replyDocs.forEach(doc => { if (doc.tweetId && doc.toId) { threadRelationships[doc.tweetId] = { replyTo: doc.toId, from: doc.from, to: doc.to, isRoot: false, timestamp }; } else if (doc.tweetId && doc.isRoot) { threadRelationships[doc.tweetId] = { replyTo: null, from: doc.from, isRoot: true, timestamp }; } }); // Save relationships to persistent storage saveThreadRelationships(); // Update the cache with thread context const batchSize = 10; for (let i = 0; i < replyDocs.length; i += batchSize) { const batch = replyDocs.slice(i, i + batchSize); batch.forEach(doc => { if (doc.tweetId && tweetCache.has(doc.tweetId)) { tweetCache.get(doc.tweetId).threadContext = { replyTo: doc.to, replyToId: doc.toId, isRoot: doc.isRoot, threadMediaUrls: doc.isRoot ? [] : getAllPreviousMediaUrls(doc.tweetId, replyDocs) }; // If this was just mapped, force reprocessing to use improved context if (doc.tweetId && processedTweets.has(doc.tweetId)) { // Find the corresponding tweet article from our collected tweet cells const tweetCell = tweetCells.find(tc => tc.tweetId === doc.tweetId); if (tweetCell && tweetCell.tweetNode) { // Don't reprocess if the tweet is currently streaming const isStreaming = tweetCell.tweetNode.dataset.ratingStatus === 'streaming' || (tweetCache.has(doc.tweetId) && tweetCache.get(doc.tweetId).streaming === true); if (!isStreaming) { processedTweets.delete(doc.tweetId); scheduleTweetProcessing(tweetCell.tweetNode); } } } } }); // Yield to main thread every batch to avoid locking UI if (i + batchSize < replyDocs.length) { await new Promise(resolve => setTimeout(resolve, 0)); } } // Mark mapping as complete delete conversation.dataset.threadMappingInProgress; threadMappingInProgress = false; conversation.dataset.threadMappedAt = Date.now().toString(); // Update timestamp on successful completion // console.log(`[mapThreadStructure] Successfully completed and set threadMappedAt to ${conversation.dataset.threadMappedAt}`); }; // Helper function to get all media URLs from tweets that came before the current one in the thread function getAllPreviousMediaUrls(tweetId, replyDocs) { const allMediaUrls = []; const index = replyDocs.findIndex(doc => doc.tweetId === tweetId); if (index > 0) { for (let i = 0; i < index; i++) { if (replyDocs[i].mediaLinks && replyDocs[i].mediaLinks.length) { allMediaUrls.push(...replyDocs[i].mediaLinks); } if (replyDocs[i].quotedMediaLinks && replyDocs[i].quotedMediaLinks.length) { allMediaUrls.push(...replyDocs[i].quotedMediaLinks); } } } return allMediaUrls; } // Race the mapping against the timeout await Promise.race([mapping(), timeout]); } catch (error) { console.error("Error in mapThreadStructure:", error); // Clear the mapped timestamp and in-progress flag so we can try again later delete conversation.dataset.threadMappingInProgress; threadMappingInProgress = false; // console.error("[mapThreadStructure] Error, not updating threadMappedAt."); } } // For use in getFullContext to check if a tweet is a reply using persistent relationships function getTweetReplyInfo(tweetId) { if (threadRelationships[tweetId]) { return threadRelationships[tweetId]; } return null; } // At the end of the file setInterval(handleThreads, THREAD_CHECK_INTERVAL); setInterval(ensureAllTweetsRated, SWEEP_INTERVAL); setInterval(applyFilteringToAll, SWEEP_INTERVAL); // ----- api/api_requests.js ----- // src/api_requests.js /** * Gets a completion from OpenRouter API * * @param {CompletionRequest} request - The completion request * @param {string} apiKey - OpenRouter API key * @param {number} [timeout=30000] - Request timeout in milliseconds * @returns {Promise<CompletionResult>} The completion result */ async function getCompletion(request, apiKey, timeout = 30000) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "POST", url: "https://openrouter.ai/api/v1/chat/completions", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://gf.qytechs.cn/en/scripts/532459-tweetfilter-ai", "X-Title": "TweetFilter-AI" }, data: JSON.stringify(request), timeout: timeout, onload: function (response) { if (response.status >= 200 && response.status < 300) { try { const data = JSON.parse(response.responseText); if (data.content==="") { resolve({ error: true, message: `No content returned${data.choices[0].native_finish_reason=="SAFETY"?" (SAFETY FILTER)":""}`, data: data }); } resolve({ error: false, message: "Request successful", data: data }); } catch (error) { resolve({ error: true, message: `Failed to parse response: ${error.message}`, data: null }); } } else { resolve({ error: true, message: `Request failed with status ${response.status}: ${response.responseText}`, data: null }); } }, onerror: function (error) { resolve({ error: true, message: `Request error: ${error.toString()}`, data: null }); }, ontimeout: function () { resolve({ error: true, message: `Request timed out after ${timeout}ms`, data: null }); } }); }); } /** * Gets a streaming completion from OpenRouter API * * @param {CompletionRequest} request - The completion request * @param {string} apiKey - OpenRouter API key * @param {Function} onChunk - Callback for each chunk of streamed response * @param {Function} onComplete - Callback when streaming is complete * @param {Function} onError - Callback when an error occurs * @param {number} [timeout=30000] - Request timeout in milliseconds * @param {string} [tweetId=null] - Optional tweet ID to associate with this request * @returns {Object} The request object with an abort method */ function getCompletionStreaming(request, apiKey, onChunk, onComplete, onError, timeout = 90000, tweetId = null) { // Add stream parameter to request const streamingRequest = { ...request, stream: true }; let fullResponse = ""; let content = ""; let reasoning = ""; // Add a variable to track reasoning content let responseObj = null; let streamComplete = false; console.log(streamingRequest); const reqObj = GM_xmlhttpRequest({ method: "POST", url: "https://openrouter.ai/api/v1/chat/completions", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://gf.qytechs.cn/en/scripts/532459-tweetfilter-ai", "X-Title": "TweetFilter-AI" }, data: JSON.stringify(streamingRequest), timeout: timeout, responseType: "stream", onloadstart: function(response) { // Get the ReadableStream from the response const reader = response.response.getReader(); // Setup timeout to prevent hanging indefinitely let streamTimeout = null; let firstChunkReceived = false; const resetStreamTimeout = () => { if (streamTimeout) clearTimeout(streamTimeout); streamTimeout = setTimeout(() => { console.log("Stream timed out after inactivity"); if (!streamComplete) { streamComplete = true; // Call onComplete with whatever we have so far onComplete({ content: content, reasoning: reasoning, fullResponse: fullResponse, data: responseObj, timedOut: true }); } }, 30000); }; // Process the stream const processStream = async () => { try { let isDone = false; let emptyChunksCount = 0; while (!isDone && !streamComplete) { const { done, value } = await reader.read(); if (done) { isDone = true; break; } if (!firstChunkReceived) { firstChunkReceived = true; resetStreamTimeout(); } // Convert the chunk to text const chunk = new TextDecoder().decode(value); clearTimeout(streamTimeout); // Reset timeout on activity resetStreamTimeout(); // Check for empty chunks - may indicate end of stream if (chunk.trim() === '') { emptyChunksCount++; // After receiving 3 consecutive empty chunks, consider the stream done if (emptyChunksCount >= 3) { isDone = true; break; } continue; } emptyChunksCount = 0; // Reset the counter if we got content fullResponse += chunk; // Split by lines - server-sent events format const lines = chunk.split("\n"); for (const line of lines) { if (line.startsWith("data: ")) { const data = line.substring(6); // Check for the end of the stream if (data === "[DONE]") { isDone = true; break; } try { const parsed = JSON.parse(data); responseObj = parsed; // Extract the content and reasoning if (parsed.choices && parsed.choices[0]) { // Check for delta content if (parsed.choices[0].delta && parsed.choices[0].delta.content !== undefined) { const delta = parsed.choices[0].delta.content || ""; content += delta; } // Check for reasoning in delta if (parsed.choices[0].delta && parsed.choices[0].delta.reasoning !== undefined) { const reasoningDelta = parsed.choices[0].delta.reasoning || ""; reasoning += reasoningDelta; } // Call the chunk callback onChunk({ chunk: parsed.choices[0].delta?.content || "", reasoningChunk: parsed.choices[0].delta?.reasoning || "", content: content, reasoning: reasoning, data: parsed }); } } catch (e) { console.error("Error parsing SSE data:", e, data); } } } } // When done, call the complete callback if not already completed if (!streamComplete) { streamComplete = true; if (streamTimeout) clearTimeout(streamTimeout); // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onComplete({ content: content, reasoning: reasoning, fullResponse: fullResponse, data: responseObj }); } } catch (error) { console.error("Stream processing error:", error); // Make sure we clean up and call onError if (streamTimeout) clearTimeout(streamTimeout); if (!streamComplete) { streamComplete = true; // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onError({ error: true, message: `Stream processing error: ${error.toString()}`, data: null }); } } }; processStream().catch(error => { console.error("Unhandled stream error:", error); if (streamTimeout) clearTimeout(streamTimeout); if (!streamComplete) { streamComplete = true; // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onError({ error: true, message: `Unhandled stream error: ${error.toString()}`, data: null }); } }); }, onerror: function(error) { // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onError({ error: true, message: `Request error: ${error.toString()}`, data: null }); }, ontimeout: function() { // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } onError({ error: true, message: `Request timed out after ${timeout}ms`, data: null }); } }); // Create an object with an abort method that can be called to cancel the request const streamingRequestObj = { abort: function() { streamComplete = true; // Set flag to prevent further processing pendingRequests--; try { reqObj.abort(); // Attempt to abort the XHR request } catch (e) { console.error("Error aborting request:", e); } // Remove from active requests tracking if (tweetId && window.activeStreamingRequests) { delete window.activeStreamingRequests[tweetId]; } // Remove incomplete entry from cache if (tweetId && tweetCache.has(tweetId)) { const entry = tweetCache.get(tweetId); // Only delete if it's a streaming entry without a score if (entry.streaming && (entry.score === undefined || entry.score === null)) { tweetCache.delete(tweetId); } } } }; // Track this request if we have a tweet ID if (tweetId && window.activeStreamingRequests) { window.activeStreamingRequests[tweetId] = streamingRequestObj; } return streamingRequestObj; } let isOnlineListenerAttached = false; // Flag to ensure listener is only added once /** * Fetches the list of available models from the OpenRouter API. * Uses the stored API key, and updates the model selector upon success. */ function fetchAvailableModels() { const apiKey = browserGet('openrouter-api-key', ''); if (!apiKey) { showStatus('Please enter your OpenRouter API key'); return; } showStatus('Fetching available models...'); const sortOrder = browserGet('modelSortOrder', 'throughput-high-to-low'); // Named function to handle the 'online' event function handleOnline() { showStatus('Back online. Fetching models...'); fetchAvailableModels(); // Retry fetching models window.removeEventListener('online', handleOnline); // Remove the listener isOnlineListenerAttached = false; // Reset the flag } GM_xmlhttpRequest({ method: "GET", url: `https://openrouter.ai/api/frontend/models/find?order=${sortOrder}`, headers: { "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://gf.qytechs.cn/en/scripts/532182-twitter-x-ai-tweet-filter", "X-Title": "Tweet Rating Tool" }, onload: function (response) { try { const data = JSON.parse(response.responseText); if (data.data && data.data.models) { //filter all models that don't have key "endpoint" or endpoint is null let filteredModels = data.data.models.filter(model => model.endpoint && model.endpoint !== null); // Assign the slug from model.endpoint.model_variant_slug filteredModels.forEach(model => { // Use model.endpoint.model_variant_slug as the primary source for the slug let currentSlug = model.endpoint?.model_variant_slug || model.id; // Fallback to model.id if slug is not present model.slug = currentSlug; // Assign the processed slug back to model.slug for consistency elsewhere }); // Reverse initial order for latency sorting to match High-Low expectations if (sortOrder === 'latency-low-to-high'|| sortOrder === 'pricing-low-to-high') { filteredModels.reverse(); } availableModels = filteredModels || []; listedModels = [...availableModels]; // Initialize listedModels refreshModelsUI(); showStatus('Models updated!'); } } catch (error) { console.error('Error parsing model list:', error); showStatus('Error parsing models list'); } }, onerror: function (error) { console.error('Error fetching models:', error); if (!navigator.onLine) { if (!isOnlineListenerAttached) { showStatus('Offline. Will attempt to fetch models when connection returns.'); window.addEventListener('online', handleOnline); isOnlineListenerAttached = true; } else { showStatus('Still offline. Waiting for connection to fetch models.'); } } else { showStatus('Error fetching models!'); } } }); } /** * Gets descriptions for images using the OpenRouter API * * @param {string[]} urls - Array of image URLs to get descriptions for * @param {string} apiKey - The API key for authentication * @param {string} tweetId - The unique tweet ID * @param {string} userHandle - The Twitter user handle * @returns {Promise<string>} Combined image descriptions */ async function getImageDescription(urls, apiKey, tweetId, userHandle) { const imageDescriptionsEnabled = browserGet('enableImageDescriptions', false); if (!urls?.length || !imageDescriptionsEnabled) { return !imageDescriptionsEnabled ? '[Image descriptions disabled]' : ''; } let descriptions = []; for (const url of urls) { const request = { model: selectedImageModel, messages: [{ role: "user", content: [ { type: "text", text: "Describe what you see in this image in a concise way, focusing on the main elements and any text visible. Keep the description under 100 words." }, { type: "image_url", image_url: { url } } ] }], temperature: imageModelTemperature, top_p: imageModelTopP, max_tokens: maxTokens, }; if (selectedImageModel.includes('gemini')) { request.config = { safetySettings: safetySettings, } } if (providerSort) { request.provider = { sort: providerSort, allow_fallbacks: true }; } const result = await getCompletion(request, apiKey); if (!result.error && result.data?.choices?.[0]?.message?.content) { descriptions.push(result.data.choices[0].message.content); } else { descriptions.push('[Error getting image description]'); } } return descriptions.map((desc, i) => `[IMAGE ${i + 1}]: ${desc}`).join('\n'); } /** * Fetches generation metadata from OpenRouter API by ID. * * @param {string} generationId - The ID of the generation to fetch metadata for. * @param {string} apiKey - OpenRouter API key. * @param {number} [timeout=10000] - Request timeout in milliseconds. * @returns {Promise<CompletionResult>} The result containing metadata or an error. */ async function getGenerationMetadata(generationId, apiKey, timeout = 10000) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url: `https://openrouter.ai/api/v1/generation?id=${generationId}`, headers: { "Authorization": `Bearer ${apiKey}`, "HTTP-Referer": "https://gf.qytechs.cn/en/scripts/532459-tweetfilter-ai", // Use your script's URL "X-Title": "TweetFilter-AI" // Replace with your script's name }, timeout: timeout, onload: function(response) { if (response.status >= 200 && response.status < 300) { try { const data = JSON.parse(response.responseText); resolve({ error: false, message: "Metadata fetched successfully", data: data // The structure is { data: { ...metadata... } } }); } catch (error) { resolve({ error: true, message: `Failed to parse metadata response: ${error.message}`, data: null }); } } else if (response.status === 404) { resolve({ error: true, status: 404, // Indicate not found specifically for retry logic message: `Generation metadata not found (404): ${response.responseText}`, data: null }); } else { resolve({ error: true, status: response.status, message: `Metadata request failed with status ${response.status}: ${response.responseText}`, data: null }); } }, onerror: function(error) { resolve({ error: true, message: `Metadata request error: ${error.toString()}`, data: null }); }, ontimeout: function() { resolve({ error: true, message: `Metadata request timed out after ${timeout}ms`, data: null }); } }); }); } // Export the functions // export { // getCompletion, // getCompletionStreaming, // fetchAvailableModels, // getImageDescription // }; // ----- api/api.js ----- // src/api.js //import { getCompletion, getCompletionStreaming, fetchAvailableModels, getImageDescription } from './api_requests.js'; /** * Formats description text for the tooltip. * Copy of the function from ui.js to ensure it's available for streaming. */ const safetySettings = [ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_NONE", }, { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "BLOCK_NONE", }, ]; /** * Extracts follow-up questions from the AI response content. * @param {string} content - The full AI response content. * @returns {string[]} An array of 3 questions, or an empty array if not found. */ function extractFollowUpQuestions(content) { if (!content) return []; const questions = []; const q1Marker = "Q_1."; const q2Marker = "Q_2."; const q3Marker = "Q_3."; const q1Start = content.indexOf(q1Marker); const q2Start = content.indexOf(q2Marker); const q3Start = content.indexOf(q3Marker); // Ensure all markers are present and in the correct order if (q1Start !== -1 && q2Start > q1Start && q3Start > q2Start) { // Extract Q1: text between Q_1. and Q_2. const q1Text = content.substring(q1Start + q1Marker.length, q2Start).trim(); questions.push(q1Text); // Extract Q2: text between Q_2. and Q_3. const q2Text = content.substring(q2Start + q2Marker.length, q3Start).trim(); questions.push(q2Text); // Extract Q3: text after Q_3. until the end of the content // (Or potentially until the next major marker if the prompt changes later) let q3Text = content.substring(q3Start + q3Marker.length).trim(); // Remove any trailing markers from Q3 if necessary const endMarker = "</FOLLOW_UP_QUESTIONS>"; if (q3Text.endsWith(endMarker)) { q3Text = q3Text.substring(0, q3Text.length - endMarker.length).trim(); } questions.push(q3Text); // Basic validation: Ensure questions are not empty if (questions.every(q => q.length > 0)) { return questions; } } // If markers aren't found or questions are empty, return empty array console.warn("[extractFollowUpQuestions] Failed to find or parse Q_1/Q_2/Q_3 markers."); return []; } /** * Rates a tweet using the OpenRouter API with automatic retry functionality. * * @param {string} tweetText - The text content of the tweet * @param {string} tweetId - The unique tweet ID * @param {string} apiKey - The API key for authentication * @param {string[]} mediaUrls - Array of media URLs associated with the tweet * @param {number} [maxRetries=3] - Maximum number of retry attempts * @param {Element} [tweetArticle=null] - Optional: The tweet article DOM element (for streaming updates) * @returns {Promise<{score: number, content: string, error: boolean, cached?: boolean, data?: any, questions?: string[]}>} The rating result */ async function rateTweetWithOpenRouter(tweetText, tweetId, apiKey, mediaUrls, maxRetries = 3, tweetArticle = null, authorHandle="") { console.log("given tweettext\n", tweetText); const cleanupRequest = () => { pendingRequests = Math.max(0, pendingRequests - 1); showStatus(`Rating tweet... (${pendingRequests} pending)`); }; const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle); if (!indicatorInstance) { console.error(`[API rateTweetWithOpenRouter] Could not get/create ScoreIndicator for ${tweetId}.`); // Cannot proceed without an indicator instance to store qaConversationHistory return { score: 5, // Default error score content: "Failed to initialize UI components for rating.", reasoning: "", questions: [], lastAnswer: "", error: true, cached: false, data: null, qaConversationHistory: [] // Empty history }; } if (adAuthorCache.has(authorHandle)) { // ... existing ad author handling ... indicatorInstance.updateInitialReviewAndBuildHistory({ fullContext: tweetText, // or a specific ad message mediaUrls: [], 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>", reviewSystemPrompt: REVIEW_SYSTEM_PROMPT, // Globally available from config.js followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT, // Globally available from config.js userInstructions: currentInstructions }); return { score: 0, content: indicatorInstance.description, reasoning: "", error: false, cached: false, questions: indicatorInstance.questions, qaConversationHistory: indicatorInstance.qaConversationHistory }; } const currentInstructions = instructionsManager.getCurrentInstructions(); const effectiveModel = browserGet('enableWebSearch', false) ? `${selectedModel}:online` : selectedModel; const requestBody = { model: effectiveModel, messages: [ { role: "system", content: [{ type: "text", text: REVIEW_SYSTEM_PROMPT + ` USER'S CUSTOM INSTRUCTIONS: ${currentInstructions}`}] }, { role: "user", content: [ { type: "text", text: `<TARGET_TWEET_ID>[${tweetId}]</TARGET_TWEET_ID> <TWEET>[${tweetText}]</TWEET> Follow this expected response format exactly, or you break the UI: EXPECTED_RESPONSE_FORMAT:\n <ANALYSIS>\n \n(Your analysis according to the user instructions. Follow the user instructions EXACTLY.) </ANALYSIS>\n <SCORE>\n SCORE_X (Where X is a number between 0 and 10, unless the user requests a different range)\n </SCORE>\n <FOLLOW_UP_QUESTIONS>\n Q_1. …\n Q_2. …\n Q_3. …\n </FOLLOW_UP_QUESTIONS> ` } ] } ], temperature: modelTemperature, top_p: modelTopP, max_tokens: maxTokens }; /* // Simplified user message text, relying on system prompt for full format instruction requestBody.messages[1].content[0].text = `<TARGET_TWEET_ID>[${tweetId}]</TARGET_TWEET_ID> <USER_INSTRUCTIONS>[${currentInstructions}]</USER_INSTRUCTIONS> <TWEET>[${tweetText}]</TWEET>`; */ if (selectedModel.includes('gemini')) { requestBody.config = { safetySettings: safetySettings }; } if (mediaUrls?.length > 0 && modelSupportsImages(selectedModel)) { mediaUrls.forEach(url => { if (url.startsWith('data:application/pdf')) { // Handle PDF format for models that support it requestBody.messages[1].content.push({ type: "file", file: { filename: "attachment.pdf", file_data: url } }); } else { // Handle images as before requestBody.messages[1].content.push({ type: "image_url", image_url: { "url": url } }); } }); } if (providerSort) { requestBody.provider = { sort: providerSort, allow_fallbacks: true }; } const useStreaming = browserGet('enableStreaming', false); // Initial cache entry for streaming - qaConversationHistory will be added later tweetCache.set(tweetId, { streaming: true, timestamp: Date.now(), tweetContent: tweetText, // Store original tweet text for context mediaUrls: mediaUrls // Store original media URLs }); let attempt = 0; while (attempt < maxRetries) { attempt++; // Rate limiting const now = Date.now(); const timeElapsed = now - lastAPICallTime; if (timeElapsed < API_CALL_DELAY_MS) { await new Promise(resolve => setTimeout(resolve, Math.max(0, API_CALL_DELAY_MS - timeElapsed))); } lastAPICallTime = now; // Update status pendingRequests++; showStatus(`Rating tweet... (${pendingRequests} pending)`); try { let result; if (useStreaming) { result = await rateTweetStreaming(requestBody, apiKey, tweetId, tweetText, tweetArticle); } else { result = await rateTweet(requestBody, apiKey); } cleanupRequest(); if (!result.error && result.content) { indicatorInstance.updateInitialReviewAndBuildHistory({ fullContext: tweetText, // The full text of the tweet that was rated mediaUrls: mediaUrls, // The media URLs associated with that tweet apiResponseContent: result.content, reviewSystemPrompt: REVIEW_SYSTEM_PROMPT, followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT, userInstructions: currentInstructions }); const finalScore = indicatorInstance.score; const finalQuestions = indicatorInstance.questions; const finalDescription = indicatorInstance.description; // Analysis part const finalQaHistory = indicatorInstance.qaConversationHistory; tweetCache.set(tweetId, { score: finalScore, description: finalDescription, // Analysis reasoning: result.reasoning || "", // If rateTweet/Streaming provide it separately questions: finalQuestions, lastAnswer: "", tweetContent: tweetText, mediaUrls: mediaUrls, streaming: false, timestamp: Date.now(), metadata: result.data?.id ? { generationId: result.data.id } : null, qaConversationHistory: finalQaHistory // Store the history }); return { score: finalScore, content: result.content, // Keep raw content for direct use if needed reasoning: result.reasoning || "", questions: finalQuestions, error: false, cached: false, data: result.data, qaConversationHistory: finalQaHistory }; } // Retry logic if result was error or no content if (attempt < maxRetries && (result.error || !result.content)) { const backoffDelay = Math.pow(attempt, 2) * 1000; await new Promise(resolve => setTimeout(resolve, backoffDelay)); } else if (result.error || !result.content) { // Last attempt failed or no content throw new Error(result.content || "Failed to get valid rating content after multiple attempts"); } } catch (error) { cleanupRequest(); console.error(`API error during attempt ${attempt}:`, error); if (attempt < maxRetries) { const backoffDelay = Math.pow(attempt, 2) * 1000; await new Promise(resolve => setTimeout(resolve, backoffDelay)); } else { // All retries failed, update indicator and cache with error state const errorContent = `Failed to get valid rating after multiple attempts: ${error.message}`; indicatorInstance.updateInitialReviewAndBuildHistory({ fullContext: tweetText, mediaUrls: mediaUrls, 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>`, reviewSystemPrompt: REVIEW_SYSTEM_PROMPT, followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT, userInstructions: currentInstructions }); tweetCache.set(tweetId, { score: 5, description: errorContent, reasoning: "", questions: [], lastAnswer: "", error: true, tweetContent: tweetText, mediaUrls: mediaUrls, streaming: false, timestamp: Date.now(), qaConversationHistory: indicatorInstance.qaConversationHistory }); return { score: 5, content: errorContent, reasoning: "", questions: [], lastAnswer: "", error: true, data: null, qaConversationHistory: indicatorInstance.qaConversationHistory }; } } } // Fallback if loop finishes unexpectedly (should be caught by error handling within loop) cleanupRequest(); const fallbackError = "Unexpected failure in rating process."; indicatorInstance.updateInitialReviewAndBuildHistory({ fullContext: tweetText, mediaUrls: mediaUrls, 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>`, reviewSystemPrompt: REVIEW_SYSTEM_PROMPT, followUpSystemPrompt: FOLLOW_UP_SYSTEM_PROMPT, userInstructions: currentInstructions }); return { score: 5, content: fallbackError, reasoning: "", questions: [], lastAnswer: "", error: true, data: null, qaConversationHistory: indicatorInstance.qaConversationHistory }; } /** * Summarizes the custom instructions for the user * * @param {Object} request - The formatted request body * @param {string} apiKey - API key for authentication * @returns {Promise<{content: string, reasoning: string, error: boolean, data: any}>} The rating result */ async function getCustomInstructionsDescription(instructions) { const INSTRUCTION_SUMMARY_MODEL = "google/gemini-2.5-flash-preview"; const request={ model: INSTRUCTION_SUMMARY_MODEL, messages: [{ role: "system", content: [{ type: "text", text: ` Please come up with a 5-word summary of the following instructions. ` }] }, { role: "user", content: [{ type: "text", text: `Please come up with a 5-word summary of the following instructions: ${instructions} ` }] }] } let key = browserGet('openrouter-api-key'); const result = await getCompletion(request,key); if (!result.error && result.data?.choices?.[0]?.message) { const content = result.data.choices[0].message.content || ""; return { content, error: false, }; } return { error: true, content: result.error || "Unknown error" }; } /** * Performs a non-streaming tweet rating request * * @param {Object} request - The formatted request body * @param {string} apiKey - API key for authentication * @returns {Promise<{content: string, reasoning: string, error: boolean, data: any}>} The rating result */ async function rateTweet(request, apiKey) { const tweetId = request.tweetId; const existingScore = tweetCache.get(tweetId)?.score; const result = await getCompletion(request, apiKey); if (!result.error && result.data?.choices?.[0]?.message) { const content = result.data.choices[0].message.content || ""; const reasoning = result.data.choices[0].message.reasoning || ""; // Store the rating in cache const scoreMatches = content.match(/SCORE_(\d+)/g); const score = existingScore || (scoreMatches && scoreMatches.length > 0 ? parseInt(scoreMatches[scoreMatches.length - 1].match(/SCORE_(\d+)/)[1], 10) : null); tweetCache.set(tweetId, { score: score, description: content, tweetContent: request.tweetText, streaming: false }); return { content, reasoning }; } return { error: true, content: result.error || "Unknown error", reasoning: "", data: null }; } /** * Performs a streaming tweet rating request with real-time UI updates * * @param {Object} request - The formatted request body * @param {string} apiKey - API key for authentication * @param {string} tweetId - The tweet ID * @param {string} tweetText - The text content of the tweet * @param {Element} tweetArticle - Optional: The tweet article DOM element (for streaming updates) * @returns {Promise<{content: string, reasoning: string, error: boolean, data: any}>} The rating result including final content and reasoning */ async function rateTweetStreaming(request, apiKey, tweetId, tweetText, tweetArticle) { // Check if there's already an active streaming request for this tweet if (window.activeStreamingRequests && window.activeStreamingRequests[tweetId]) { console.log(`Aborting existing streaming request for tweet ${tweetId}`); window.activeStreamingRequests[tweetId].abort(); delete window.activeStreamingRequests[tweetId]; } // Store initial streaming entry only if not already cached with a score const existingCache = tweetCache.get(tweetId); if (!existingCache || existingCache.score === undefined || existingCache.score === null) { tweetCache.set(tweetId, { streaming: true, timestamp: Date.now(), tweetContent: tweetText, description: "", reasoning: "", questions: [], lastAnswer: "", score: null }); } return new Promise((resolve, reject) => { // Get or create the indicator instance *once* // Use the passed-in tweetArticle const indicatorInstance = ScoreIndicatorRegistry.get(tweetId, tweetArticle); if (!indicatorInstance) { console.error(`[API Stream] Could not get/create ScoreIndicator for ${tweetId}. Aborting stream setup.`); // Update cache to reflect error/non-streaming state if (tweetCache.has(tweetId)) { tweetCache.get(tweetId).streaming = false; tweetCache.get(tweetId).error = "Indicator initialization failed"; } return reject(new Error(`ScoreIndicator instance could not be initialized for tweet ${tweetId}`)); } let aggregatedContent = existingCache?.description || ""; let aggregatedReasoning = existingCache?.reasoning || ""; let aggregatedQuestions = existingCache?.questions || []; let finalData = null; let score = existingCache?.score || null; getCompletionStreaming( request, apiKey, // onChunk callback - update the ScoreIndicator instance (chunkData) => { aggregatedContent = chunkData.content || aggregatedContent; aggregatedReasoning = chunkData.reasoning || aggregatedReasoning; // Look for a score in the accumulated content so far const scoreMatches = aggregatedContent.match(/SCORE_(\d+)/g); // Use global flag to get all matches // Always use the last score found in the stream if (scoreMatches && scoreMatches.length > 0) { const lastScore = scoreMatches[scoreMatches.length - 1]; score = parseInt(lastScore.match(/SCORE_(\d+)/)[1], 10); } // Update the instance indicatorInstance.update({ status: 'streaming', score: score, description: aggregatedContent || "Rating in progress...", reasoning: aggregatedReasoning, questions: [], lastAnswer: "" }); // Update cache with partial data during streaming if (tweetCache.has(tweetId)) { const entry = tweetCache.get(tweetId); entry.description = aggregatedContent; entry.reasoning = aggregatedReasoning; entry.score = score; entry.streaming = true; // Still streaming } }, // onComplete callback - finalize the rating (finalResult) => { console.log(finalResult); aggregatedContent = finalResult.content || aggregatedContent; aggregatedReasoning = finalResult.reasoning || aggregatedReasoning; finalData = finalResult.data; // console.log("Final stream data:", finalData); // Final check for score const scoreMatches = aggregatedContent.match(/SCORE_(\d+)/g); if (scoreMatches && scoreMatches.length > 0) { const lastScore = scoreMatches[scoreMatches.length - 1]; score = parseInt(lastScore.match(/SCORE_(\d+)/)[1], 10); } let finalStatus = 'rated'; // If no score was found anywhere, mark as error if (score === null || score === undefined) { console.warn(`[API Stream] No score found in final content for tweet ${tweetId}. Content: ${aggregatedContent.substring(0, 100)}...`); finalStatus = 'error'; score = 5; // Assign default error score aggregatedContent += "\n[No score detected - Error]"; } // Store final result in cache (non-streaming) const finalCacheData = { tweetContent: tweetText, score: score, description: aggregatedContent, reasoning: aggregatedReasoning, streaming: false, timestamp: Date.now(), error: finalStatus === 'error' ? "No score detected" : undefined, metadata: finalData?.id ? { generationId: finalData.id } : null }; tweetCache.set(tweetId, finalCacheData); // Finalize UI update via instance indicatorInstance.update({ status: finalStatus, score: score, description: aggregatedContent, reasoning: aggregatedReasoning, questions: extractFollowUpQuestions(aggregatedContent), lastAnswer: "", metadata: finalData?.id ? { generationId: finalData.id } : null }); if (tweetArticle) { filterSingleTweet(tweetArticle); } // --- Fetch Generation Metadata (New) --- const generationId = finalData?.id; if (generationId && apiKey) { fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance); } // --- End Fetch Generation Metadata --- resolve({ score: score, content: aggregatedContent, reasoning: aggregatedReasoning, error: finalStatus === 'error', cached: false, data: finalData }); }, // onError callback (errorData) => { console.error(`[API Stream Error] Tweet ${tweetId}: ${errorData.message}`); // Update UI via instance to show error indicatorInstance.update({ status: 'error', score: 5, description: `Stream Error: ${errorData.message}`, reasoning: '', questions: [], lastAnswer: '' }); // Update cache to reflect error if (tweetCache.has(tweetId)) { const entry = tweetCache.get(tweetId); entry.streaming = false; entry.error = errorData.message; entry.score = 5; // Store default error score in cache too entry.description = `Stream Error: ${errorData.message}`; // Store error message } reject(new Error(errorData.message)); // Reject the promise }, 30000, tweetId // Pass the tweet ID to associate with this request ); }); } /** * Fetches generation metadata with retry logic and updates cache/UI. * @param {string} tweetId * @param {string} generationId * @param {string} apiKey * @param {ScoreIndicator} indicatorInstance - The indicator instance to update. * @param {number} [attempt=0] * @param {number[]} [delays=[1000, 500, 2000, 4000, 8000]] */ async function fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance, attempt = 0, delays = [1000, 500, 2000, 4000, 8000]) { if (attempt >= delays.length) { console.warn(`[Metadata Fetch ${tweetId}] Max retries reached for generation ${generationId}.`); return; } const delay = delays[attempt]; await new Promise(resolve => setTimeout(resolve, delay)); try { // console.log(`[Metadata Fetch ${tweetId}] Attempt ${attempt + 1} for generation ${generationId} after ${delay}ms`); const metadataResult = await getGenerationMetadata(generationId, apiKey); if (!metadataResult.error && metadataResult.data?.data) { const meta = metadataResult.data.data; // console.log(`[Metadata Fetch ${tweetId}] Success for generation ${generationId}`, meta); const extractedMetadata = { model: meta.model || 'N/A', promptTokens: meta.tokens_prompt || 0, completionTokens: meta.tokens_completion || 0, // Use this for total completion output reasoningTokens: meta.native_tokens_reasoning || 0, // Specific reasoning tokens if available latency: meta.latency !== undefined ? (meta.latency / 1000).toFixed(2) + 's' : 'N/A', // Convert ms to s mediaInputs: meta.num_media_prompt || 0, price: meta.total_cost !== undefined ? `$${meta.total_cost.toFixed(6)}` : 'N/A', // Add total cost providerName: meta.provider_name || 'N/A' // Add provider_name }; // Update the cache const currentCache = tweetCache.get(tweetId); if (currentCache) { currentCache.metadata = extractedMetadata; tweetCache.set(tweetId, currentCache); // Save updated cache entry // Update the ScoreIndicator instance indicatorInstance.update({ metadata: extractedMetadata }); console.log(`[Metadata Fetch ${tweetId}] Stored metadata and updated UI for generation ${generationId}`); } else { console.warn(`[Metadata Fetch ${tweetId}] Cache entry disappeared before metadata could be stored for generation ${generationId}.`); } return; // Success, stop retrying } else if (metadataResult.status === 404) { // console.log(`[Metadata Fetch ${tweetId}] Generation ${generationId} not found yet (404), retrying...`); fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance, attempt + 1, delays); } else { console.warn(`[Metadata Fetch ${tweetId}] Error fetching metadata (Attempt ${attempt + 1}) for ${generationId}: ${metadataResult.message}`); fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance, attempt + 1, delays); // Retry on other errors too } } catch (error) { console.error(`[Metadata Fetch ${tweetId}] Unexpected error during fetch (Attempt ${attempt + 1}) for ${generationId}:`, error); // Still retry on unexpected errors fetchAndStoreGenerationMetadata(tweetId, generationId, apiKey, indicatorInstance, attempt + 1, delays); } } /** * Answers a follow-up question about a tweet and generates new questions. * * @param {string} tweetId - The ID of the tweet being discussed. * @param {object[]} qaHistoryForApiCall - The conversation history array, including the latest user message. * @param {string} apiKey - The OpenRouter API key. * @param {Element} [tweetArticle=null] - The DOM element for the tweet article. * @param {ScoreIndicator} indicatorInstance - The ScoreIndicator instance to update. * @returns {Promise<void>} Resolves when the answer is generated and UI updated. */ async function answerFollowUpQuestion(tweetId, qaHistoryForApiCall, apiKey, tweetArticle, indicatorInstance) { const questionTextForLogging = qaHistoryForApiCall.find(m => m.role === 'user' && m === qaHistoryForApiCall[qaHistoryForApiCall.length - 1])?.content.find(c => c.type === 'text')?.text || "User's question"; console.log(`[FollowUp] Answering question for ${tweetId}: "${questionTextForLogging}" using full history.`); const useStreaming = browserGet('enableStreaming', false); // Prepare messages for the API call: template the last user message in the history const messagesForApi = qaHistoryForApiCall.map((msg, index) => { if (index === qaHistoryForApiCall.length - 1 && msg.role === 'user') { const rawUserText = msg.content.find(c => c.type === 'text')?.text || ""; const templatedText = `<UserQuestion> ${rawUserText} </UserQuestion>\n You MUST match the EXPECTED_RESPONSE_FORMAT\n EXPECTED_RESPONSE_FORMAT:\n <ANSWER>\n(Your answer here)\n</ANSWER>\n <FOLLOW_UP_QUESTIONS> (Anticipate 3 things the user may ask you next. These questions should not be directed at the user. Only pose a question if you are sure you can answer it, based off your knowledge.)\nQ_1. (New Question 1 here)\nQ_2. (New Question 2 here)\nQ_3. (New Question 3 here)\n</FOLLOW_UP_QUESTIONS>\n `; const templatedContent = [{ type: "text", text: templatedText }]; msg.content.forEach(contentItem => { if (contentItem.type === "image_url") { templatedContent.push(contentItem); } }); return { ...msg, content: templatedContent }; } return msg; // Return other messages (system prompts, previous assistant messages, previous user messages) as is }); const effectiveModel = browserGet('enableWebSearch', false) ? `${selectedModel}:online` : selectedModel; const request = { model: effectiveModel, messages: messagesForApi, // Use the history with the last user message templated temperature: modelTemperature, top_p: modelTopP, max_tokens: maxTokens, stream: useStreaming }; console.log(`followup request (templated): ${JSON.stringify(request)}`); if (selectedModel.includes('gemini')) { request.config = { safetySettings: safetySettings }; } if (providerSort) { request.provider = { sort: providerSort, allow_fallbacks: true }; } // UI update for "Thinking..." is handled by ScoreIndicator's _handleFollowUpQuestionClick try { // Outer try for the finally block try { // Inner try for existing error handling let finalAnswerContent = "*Processing...*"; // This is the raw AI response string let finalQaHistory = [...qaHistoryForApiCall]; // Start with a copy if (useStreaming) { await new Promise((resolve, reject) => { let aggregatedContent = ""; let aggregatedReasoning = ""; // Add reasoning tracking getCompletionStreaming( request, apiKey, // onChunk (chunkData) => { aggregatedContent = chunkData.content || aggregatedContent; aggregatedReasoning = chunkData.reasoning || aggregatedReasoning; // Track reasoning // Render streaming answer with reasoning indicatorInstance._renderStreamingAnswer(aggregatedContent, aggregatedReasoning); }, // onComplete (result) => { finalAnswerContent = result.content || aggregatedContent; const finalReasoning = result.reasoning || aggregatedReasoning; // Get final reasoning const assistantMessage = { role: "assistant", content: [{ type: "text", text: finalAnswerContent }] }; finalQaHistory.push(assistantMessage); // Store reasoning in last conversation history turn if (indicatorInstance.conversationHistory.length > 0) { const lastTurn = indicatorInstance.conversationHistory[indicatorInstance.conversationHistory.length - 1]; if (lastTurn.answer === 'pending') { lastTurn.reasoning = finalReasoning; } } indicatorInstance.updateAfterFollowUp({ assistantResponseContent: finalAnswerContent, updatedQaHistory: finalQaHistory }); // Update cache with the new full QA history const currentCache = tweetCache.get(tweetId) || {}; currentCache.qaConversationHistory = finalQaHistory; // also update questions and lastAnswer for compatibility if needed, though qaHistory is prime const parsedAnswer = finalAnswerContent.match(/<ANSWER>([\s\S]*?)<\/ANSWER>/); currentCache.lastAnswer = parsedAnswer ? parsedAnswer[1].trim() : finalAnswerContent; currentCache.questions = extractFollowUpQuestions(finalAnswerContent); currentCache.timestamp = Date.now(); tweetCache.set(tweetId, currentCache); resolve(); }, // onError (error) => { console.error("[FollowUp Stream Error]", error); const errorMessage = `Error generating answer: ${error.message}`; // Update ScoreIndicator's UI part of conversationHistory indicatorInstance._updateConversationHistory(questionTextForLogging, errorMessage); indicatorInstance.questions = tweetCache.get(tweetId)?.questions || []; // Restore old questions indicatorInstance._updateTooltipUI(); // Refresh // Update cache with error state for this turn if needed, though qaHistory won't have AI response const currentCache = tweetCache.get(tweetId) || {}; currentCache.lastAnswer = errorMessage; // Store error message currentCache.timestamp = Date.now(); tweetCache.set(tweetId, currentCache); reject(new Error(error.message)); }, 60000, `followup-${tweetId}` ); }); } else { // Non-streaming follow-up const result = await getCompletion(request, apiKey, 60000); if (result.error || !result.data?.choices?.[0]?.message?.content) { throw new Error(result.message || "Failed to get follow-up answer."); } finalAnswerContent = result.data.choices[0].message.content; const assistantMessage = { role: "assistant", content: [{ type: "text", text: finalAnswerContent }] }; finalQaHistory.push(assistantMessage); indicatorInstance.updateAfterFollowUp({ assistantResponseContent: finalAnswerContent, updatedQaHistory: finalQaHistory }); // Update cache const currentCache = tweetCache.get(tweetId) || {}; currentCache.qaConversationHistory = finalQaHistory; const parsedAnswer = finalAnswerContent.match(/<ANSWER>([\s\S]*?)<\/ANSWER>/); currentCache.lastAnswer = parsedAnswer ? parsedAnswer[1].trim() : finalAnswerContent; currentCache.questions = extractFollowUpQuestions(finalAnswerContent); currentCache.timestamp = Date.now(); tweetCache.set(tweetId, currentCache); } } catch (error) { console.error(`[FollowUp] Error answering question for ${tweetId}:`, error); const errorMessage = `Error answering question: ${error.message}`; indicatorInstance._updateConversationHistory(questionTextForLogging, errorMessage); // Update UI history indicatorInstance.questions = tweetCache.get(tweetId)?.questions || []; // Restore old questions from cache indicatorInstance._updateTooltipUI(); // Refresh const currentCache = tweetCache.get(tweetId) || {}; currentCache.lastAnswer = errorMessage; // Store error in cache currentCache.timestamp = Date.now(); tweetCache.set(tweetId, currentCache); // No re-throw needed, as the finally block will handle cleanup. } } finally { // This block ensures that UI elements are re-enabled regardless of success or failure. if (indicatorInstance && typeof indicatorInstance._finalizeFollowUpInteraction === 'function') { indicatorInstance._finalizeFollowUpInteraction(); } } } // Export all functions // // export { // safetySettings, // rateTweetWithOpenRouter, // getCustomInstructionsDescription, // rateTweet, // rateTweetStreaming // }; // ----- twitter-desloppifier.js ----- //src/twitter-desloppifier.js const VERSION = '1.5.4'; (function () { 'use strict'; console.log("X/Twitter Tweet De-Sloppification Activated (v1.5.4- Enhanced)"); // Load CSS stylesheet //const css = GM_getResourceText('STYLESHEET'); let menuhtml = GM_getResourceText("MENU_HTML"); browserSet('menuHTML', menuhtml); let firstRun = browserGet('firstRun', true); //GM_addStyle(css); // ----- Initialization ----- /** * Initializes the observer on the main content area, adds the UI elements, * starts processing visible tweets, and sets up periodic checks. */ function initializeObserver() { const target = document.querySelector('main') || document.querySelector('div[data-testid="primaryColumn"]'); if (target) { observedTargetNode = target; console.log("X/Twitter Tweet De-Sloppification: Target node found. Observing..."); initialiseUI(); if (firstRun) { resetSettings(true); browserSet('firstRun', false); } // If no API key is found, prompt the user let apiKey = browserGet('openrouter-api-key', ''); if(!apiKey){ alert("No API Key found. Please enter your API Key in Settings > General.") } /* if (!apiKey){ //key is dead apiKey = '*' showStatus(`No API Key Found. Using Promotional Key`); }*/ if (apiKey) { browserSet('openrouter-api-key', apiKey); showStatus(`Loaded ${tweetCache.size} cached ratings. Starting to rate visible tweets...`); fetchAvailableModels(); } if(document.querySelector('[aria-label="Timeline: Conversation"]')){ handleThreads(); }else{ observedTargetNode.querySelectorAll(TWEET_ARTICLE_SELECTOR).forEach(scheduleTweetProcessing); } const observer = new MutationObserver(handleMutations); observer.observe(observedTargetNode, { childList: true, subtree: true }); window.addEventListener('beforeunload', () => { observer.disconnect(); const sliderUI = document.getElementById('tweet-filter-container'); if (sliderUI) sliderUI.remove(); const settingsUI = document.getElementById('settings-container'); if (settingsUI) settingsUI.remove(); const statusIndicator = document.getElementById('status-indicator'); if (statusIndicator) statusIndicator.remove(); ScoreIndicatorRegistry.destroyAll(); console.log("X/Twitter Tweet De-Sloppification Deactivated."); }); } else { setTimeout(initializeObserver, 1); } } initializeObserver(); })(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址