([^<]+)<\/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, '>') // Basic escaping
.replace(/SCORE_(\d+)/g, 'SCORE: $1') // Apply highlighting
.replace(/\n/g, '
'); // 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(/\n/g, '
');
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
here, reasoning-dropdown class provides border-top styling if needed
if (this.metadata.providerName && this.metadata.providerName !== 'N/A') {
metadataHTML += `Provider: ${this.metadata.providerName}
`;
}
metadataHTML += `Model: ${this.metadata.model}
`;
metadataHTML += `Tokens: prompt: ${this.metadata.promptTokens} / completion: ${this.metadata.completionTokens}
`;
if (this.metadata.reasoningTokens > 0) {
metadataHTML += `Reasoning Tokens: ${this.metadata.reasoningTokens}
`;
}
metadataHTML += `Latency: ${this.metadata.latency}
`;
if (this.metadata.mediaInputs > 0) {
metadataHTML += `Media: ${this.metadata.mediaInputs}
`;
}
metadataHTML += `Price: ${this.metadata.price}
`;
showMetadataDropdown = true;
} else if (hasOnlyGenId) {
metadataHTML += `Generation ID: ${this.metadata.generationId} (fetching details...)
`;
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, '>'); // Basic escaping
let uploadedImageHtml = '';
if (turn.uploadedImages && turn.uploadedImages.length > 0) {
uploadedImageHtml = `
${turn.uploadedImages.map(url => {
if (url.startsWith('data:application/pdf')) {
// Display PDF icon for PDFs
return `
`;
} else {
// Display image preview
return `

`;
}
}).join('')}
`;
}
let formattedAnswer;
if (turn.answer === 'pending') {
formattedAnswer = 'Answering...';
} else {
// Apply formatting similar to the main description/reasoning
formattedAnswer = turn.answer
.replace(/```([\s\S]*?)```/g, (m, code) => `${code.replace(//g,'>')}
`)
.replace(//g, '>') // Escape potential raw HTML first
// Format markdown links: [text](url) -> text
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') // Added class
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
// 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 = '';
html += '';
headerRow.slice(1, -1).split('|').forEach(cell => {
html += `| ${cell.trim()} | `;
});
html += '
';
html += '';
bodyRows.forEach(rowStr => {
if (!rowStr.trim()) return;
html += '';
rowStr.slice(1, -1).split('|').forEach(cell => {
html += `| ${cell.trim()} | `;
});
html += '
';
});
html += '
';
return html;
})
.replace(/\n/g, '
');
}
// Add a separator before each Q&A pair except the first one
if (index > 0) {
historyHtml += '
';
}
// --- 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 = `
${arrowChar} Show Reasoning Trace
`;
}
historyHtml += `
You: ${formattedQuestion}
${uploadedImageHtml}
${reasoningHtml}
AI: ${formattedAnswer}
`;
});
// 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 = `📄
${fileName || 'PDF'}`;
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) => `${code.replace(//g,'>')}
`)
.replace(//g, '>') // Escape potential raw HTML first
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
// 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 = '';
html += '';
headerRow.slice(1, -1).split('|').forEach(cell => {
html += `| ${cell.trim()} | `;
});
html += '
';
html += '';
bodyRows.forEach(rowStr => {
if (!rowStr.trim()) return;
html += '';
rowStr.slice(1, -1).split('|').forEach(cell => {
html += `| ${cell.trim()} | `;
});
html += '
';
});
html += '
';
return html;
})
.replace(/\n/g, '
');
// Update the innerHTML directly, adding the cursor
lastAnswerElement.innerHTML = `AI: ${formattedStreamingAnswer}|`;
} 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(/([\s\S]*?)<\/ANALYSIS>/);
const scoreMatch = apiResponseContent.match(/\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 }];
if(modelSupportsImages(selectedModel)) {
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(/([\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(/([\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) => `${code.replace(//g,'>')}
`)
.replace(//g, '>') // Escape HTML tags first
// Hyperlinks [text](url)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/^# (.*$)/gm, '$1
')
.replace(/^## (.*$)/gm, '$1
')
.replace(/^### (.*$)/gm, '$1
')
.replace(/^#### (.*$)/gm, '$1
')
.replace(/\*\*(.*?)\*\*/g, '$1') // Bold
.replace(/\*(.*?)\*/g, '$1') // Italic
.replace(/`([^`]+)`/g, '$1') // Inline code
.replace(/SCORE_(\d+)/g, 'SCORE: $1') // 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 = '';
// Header
html += '';
headerRow.slice(1, -1).split('|').forEach(cell => {
html += `| ${cell.trim()} | `;
});
html += '
';
// Body
html += '';
bodyRows.forEach(rowStr => {
if (!rowStr.trim()) return; // Skip empty lines that might be caught by regex
html += '';
rowStr.slice(1, -1).split('|').forEach(cell => {
html += `| ${cell.trim()} | `;
});
html += '
';
});
html += '
';
return html;
})
.replace(/\n\n/g, '
') // Paragraph breaks
.replace(/\n/g, '
'); // 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) => `${code.replace(//g,'>')}
`)
.replace(//g, '>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\n\n/g, '
')
.replace(/\n/g, '
');
}
// 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, '✕ Close', '⚙️ 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 and video descriptions from any section
const mediaMatches1 = fullContextWithImageDescription.matchAll(/(?:\[MEDIA_URLS\]:\s*\n)(.*?)(?:\n|$)/g);
const mediaMatches2 = fullContextWithImageDescription.matchAll(/(?:\[QUOTED_TWEET_MEDIA_URLS\]:\s*\n)(.*?)(?:\n|$)/g);
const videoMatches1 = fullContextWithImageDescription.matchAll(/(?:\[VIDEO_DESCRIPTIONS\]:\s*\n)([\s\S]*?)(?:\n\[|$)/g);
const videoMatches2 = fullContextWithImageDescription.matchAll(/(?:\[QUOTED_TWEET_VIDEO_DESCRIPTIONS\]:\s*\n)([\s\S]*?)(?:\n\[|$)/g);
// Extract image URLs
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()));
}
}
// Extract video descriptions and add them back as formatted items
for (const match of videoMatches1) {
if (match[1]) {
const videoLines = match[1].trim().split('\n').filter(line => line.trim());
videoLines.forEach(line => {
if (line.startsWith('[VIDEO ')) {
const desc = line.replace(/^\[VIDEO \d+\]: /, '');
mediaURLs.push(`[VIDEO_DESCRIPTION]: ${desc}`);
}
});
}
}
for (const match of videoMatches2) {
if (match[1]) {
const videoLines = match[1].trim().split('\n').filter(line => line.trim());
videoLines.forEach(line => {
if (line.startsWith('[VIDEO ')) {
const desc = line.replace(/^\[VIDEO \d+\]: /, '');
mediaURLs.push(`[VIDEO_DESCRIPTION]: ${desc}`);
}
});
}
}
// Remove duplicates and empty items
mediaURLs = [...new Set(mediaURLs.filter(item => item.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 content (URLs or video descriptions),
// 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 content (URLs or video descriptions) was 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 content 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
// Filter out video descriptions from mediaURLs before passing to API
const filteredMediaURLs = mediaURLs.filter(item => !item.startsWith('[VIDEO_DESCRIPTION]:'));
// rateTweetWithOpenRouter now returns questions as well
const rating = await rateTweetWithOpenRouter(fullContextWithImageDescription, tweetId, apiKey, filteredMediaURLs, 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 content 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 with no depth limit
async function buildReplyChain(tweetId, maxDepth = Infinity) {
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} - 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));
// Separate video descriptions from image URLs for main media
const mainImageUrls = [];
const mainVideoDescriptions = [];
mainMediaLinks.forEach(item => {
if (item.startsWith('[VIDEO_DESCRIPTION]:')) {
mainVideoDescriptions.push(item.replace('[VIDEO_DESCRIPTION]: ', ''));
} else {
mainImageUrls.push(item);
}
});
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;
// Handle video descriptions
if (mainVideoDescriptions.length > 0) {
fullContextWithImageDescription += `
[VIDEO_DESCRIPTIONS]:
${mainVideoDescriptions.map((desc, i) => `[VIDEO ${i + 1}]: ${desc}`).join('\n')}`;
}
// Handle image URLs and descriptions
if (mainImageUrls.length > 0) {
if (browserGet('enableImageDescriptions', false)) { // Re-check enableImageDescriptions, as it might have changed
let mainMediaLinksDescription = await getImageDescription(mainImageUrls, apiKey, tweetId, userHandle);
fullContextWithImageDescription += `
[MEDIA_DESCRIPTION]:
${mainMediaLinksDescription}`;
}
fullContextWithImageDescription += `
[MEDIA_URLS]:
${mainImageUrls.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) {
// Separate video descriptions from image URLs for quoted media
const quotedImageUrls = [];
const quotedVideoDescriptions = [];
quotedMediaLinks.forEach(item => {
if (item.startsWith('[VIDEO_DESCRIPTION]:')) {
quotedVideoDescriptions.push(item.replace('[VIDEO_DESCRIPTION]: ', ''));
} else {
quotedImageUrls.push(item);
}
});
// Handle quoted video descriptions
if (quotedVideoDescriptions.length > 0) {
fullContextWithImageDescription += `
[QUOTED_TWEET_VIDEO_DESCRIPTIONS]:
${quotedVideoDescriptions.map((desc, i) => `[VIDEO ${i + 1}]: ${desc}`).join('\n')}`;
}
// Handle quoted image URLs and descriptions
if (quotedImageUrls.length > 0) {
if (browserGet('enableImageDescriptions', false)) { // Re-check enableImageDescriptions
let quotedMediaLinksDescription = await getImageDescription(quotedImageUrls, apiKey, tweetId, userHandle); // tweetId and userHandle are from main tweet for context
fullContextWithImageDescription += `
[QUOTED_TWEET_MEDIA_DESCRIPTION]:
${quotedMediaLinksDescription}`;
}
fullContextWithImageDescription += `
[QUOTED_TWEET_MEDIA_URLS]:
${quotedImageUrls.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} The completion result
*/
async function getCompletion(request, apiKey, timeout = 30000) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "POST",
url: "https://openrouter.ai/api/v1/chat/completions",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"HTTP-Referer": "https://greasyfork.org/en/scripts/532459-tweetfilter-ai",
"X-Title": "TweetFilter-AI"
},
data: JSON.stringify(request),
timeout: timeout,
onload: function (response) {
if (response.status >= 200 && response.status < 300) {
try {
const data = JSON.parse(response.responseText);
if (data.content==="") {
resolve({
error: true,
message: `No content returned${data.choices[0].native_finish_reason=="SAFETY"?" (SAFETY FILTER)":""}`,
data: data
});
}
resolve({
error: false,
message: "Request successful",
data: data
});
} catch (error) {
resolve({
error: true,
message: `Failed to parse response: ${error.message}`,
data: null
});
}
} else {
resolve({
error: true,
message: `Request failed with status ${response.status}: ${response.responseText}`,
data: null
});
}
},
onerror: function (error) {
resolve({
error: true,
message: `Request error: ${error.toString()}`,
data: null
});
},
ontimeout: function () {
resolve({
error: true,
message: `Request timed out after ${timeout}ms`,
data: null
});
}
});
});
}
/**
* Gets a streaming completion from OpenRouter API
*
* @param {CompletionRequest} request - The completion request
* @param {string} apiKey - OpenRouter API key
* @param {Function} onChunk - Callback for each chunk of streamed response
* @param {Function} onComplete - Callback when streaming is complete
* @param {Function} onError - Callback when an error occurs
* @param {number} [timeout=30000] - Request timeout in milliseconds
* @param {string} [tweetId=null] - Optional tweet ID to associate with this request
* @returns {Object} The request object with an abort method
*/
function getCompletionStreaming(request, apiKey, onChunk, onComplete, onError, timeout = 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://greasyfork.org/en/scripts/532459-tweetfilter-ai",
"X-Title": "TweetFilter-AI"
},
data: JSON.stringify(streamingRequest),
timeout: timeout,
responseType: "stream",
onloadstart: function(response) {
// Get the ReadableStream from the response
const reader = response.response.getReader();
// Setup timeout to prevent hanging indefinitely
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://greasyfork.org/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} 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 this image. Include any text visible in the image, try to describe the image in a way that preserves all of the information and context present in the image."
},
{
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} 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://greasyfork.org/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 = "";
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)) {
indicatorInstance.updateInitialReviewAndBuildHistory({
fullContext: tweetText,
mediaUrls: [],
apiResponseContent: "