// Debug logging utility - respects the debug setting from server config
function aiDebugLog(...args) {
if (window.config && window.config.debug) {
console.log(...args);
}
}
class AIAssistantManager {
constructor() {
this.loadingStates = new Set();
this.activeTooltips = new Set();
this.keywordHighlightStates = new Map();
this.highlightedLabels = new Map(); // Track highlighted labels by annotationId
this.colors = {}; // Label colors loaded from API
// Add new AI assistant types here
// Capability flags indicate what each assistant requires:
// - requiresTextInput: Only works with text content (disabled for images)
// - requiresVision: Only works with image/video content (disabled for text)
this.assistantConfig = {
hint: {
apiEndpoint: '/api/get_ai_suggestion',
loadingText: 'Loading hint...',
errorText: 'Failed to load hint',
className: 'hint-tooltip',
requiresTextInput: false, // Works with both text and images
requiresVision: false,
},
keyword: {
apiEndpoint: '/api/get_ai_suggestion',
loadingText: 'Loading keywords...',
errorText: 'Failed to load keywords',
className: 'keyword-tooltip',
requiresTextInput: true, // Only works with text (keywords don't apply to images)
requiresVision: false,
},
rationale: {
apiEndpoint: '/api/get_ai_suggestion',
loadingText: 'Loading rationales...',
errorText: 'Failed to load rationales',
className: 'rationale-tooltip',
requiresTextInput: false, // Works with both text and images
requiresVision: false,
},
detection: {
apiEndpoint: '/api/get_ai_suggestion',
loadingText: 'Detecting objects...',
errorText: 'Failed to detect objects',
className: 'detection-tooltip',
requiresTextInput: false,
requiresVision: true, // Only works with images
},
pre_annotate: {
apiEndpoint: '/api/get_ai_suggestion',
loadingText: 'Pre-annotating...',
errorText: 'Failed to pre-annotate',
className: 'pre-annotate-tooltip',
requiresTextInput: false,
requiresVision: true, // Only works with images
},
};
this.init();
}
init() {
this.loadColors();
this.setupEventDelegation();
this.setupClickOutside();
}
/**
* Check if the current instance content is an image
* @returns {boolean} True if content appears to be an image URL
*/
isImageContent() {
const textContent = document.getElementById('text-content');
const instanceText = document.getElementById('instance-text');
// Check for image annotation container
const imageAnnotationContainer = document.querySelector('.image-annotation-container');
if (imageAnnotationContainer) {
return true;
}
// Check text content for image URL patterns
const text = textContent?.textContent || instanceText?.textContent || '';
const textLower = text.toLowerCase().trim();
// Check for common image extensions
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
if (imageExtensions.some(ext => textLower.includes(ext))) {
return true;
}
// Check for image hosting services
const imageHosts = ['unsplash.com', 'imgur.com', 'flickr.com', 'picsum.photos'];
if (imageHosts.some(host => textLower.includes(host))) {
return true;
}
// Check if URL starts with http and contains 'image'
if (textLower.startsWith('http') && textLower.includes('image')) {
return true;
}
return false;
}
/**
* Check if a specific assistant type should be shown based on content type
* @param {string} assistantType - The type of assistant (hint, keyword, rationale, etc.)
* @returns {boolean} True if the button should be shown
*/
shouldShowButton(assistantType) {
const config = this.assistantConfig[assistantType];
if (!config) {
return true; // Unknown type, show by default
}
const isImage = this.isImageContent();
// Check if assistant requires text input but content is an image
if (config.requiresTextInput && isImage) {
aiDebugLog(`[AIAssistant] Hiding ${assistantType} button - requires text input but content is image`);
return false;
}
// Check if assistant requires vision but content is text
if (config.requiresVision && !isImage) {
aiDebugLog(`[AIAssistant] Hiding ${assistantType} button - requires vision but content is text`);
return false;
}
return true;
}
/**
* Load label colors from the server API
*/
async loadColors() {
try {
const response = await fetch('/api/colors');
if (response.ok) {
this.colors = await response.json();
console.log('[AIAssistant] Colors loaded:', this.colors);
}
} catch (error) {
console.warn('[AIAssistant] Error loading colors:', error);
}
}
setupClickOutside() {
document.addEventListener('click', (event) => {
// Don't close if clicking on a tooltip or its contents
const clickedTooltip = event.target.closest('.tooltip');
const clickedAiHelper = event.target.closest('.ai-help, .ai-assistant-container');
const clickedAiOverlay = event.target.closest('#instance-text');
const clickedOption = event.target.closest(".shadcn-span-option");
// If clicked outside tooltips and AI helper elements, close all tooltips
if (!clickedTooltip && !clickedAiHelper && !clickedAiOverlay && !clickedOption) {
this.closeAllTooltips();
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
this.closeAllTooltips();
}
});
}
setupEventDelegation() {
document.querySelectorAll('.ai-help').forEach((node, index) => {
const annotationForm = node.closest('.annotation-form');
if (!annotationForm) return;
const annotationId = annotationForm.getAttribute("data-annotation-id");
// Note: We DON'T capture tooltip here because getAiAssistantName() replaces
// the aiHelp innerHTML later, which creates a new tooltip element.
// Instead, we query for the tooltip fresh in the click handler.
node.addEventListener("click", (event) => {
const clickedHint = event.target.closest('.hint');
const clickKeyword = event.target.closest('.keyword');
const clickedRationale = event.target.closest('.rationale');
// Query tooltip fresh each time - it may have been replaced by getAiAssistantName()
const tooltip = node.querySelector('.tooltip');
console.log('[AIAssistant] Click detected on ai-help:', {
target: event.target.className,
clickedHint: !!clickedHint,
clickKeyword: !!clickKeyword,
clickedRationale: !!clickedRationale,
annotationId,
hasTooltip: !!tooltip
});
event.stopPropagation();
event.preventDefault();
// Add new ai suggestion
if (clickedHint && node.contains(clickedHint)) {
this.toggleAssistant("hint", annotationId, tooltip);
} else if (clickKeyword && node.contains(clickKeyword)) {
this.toggleAssistant("keyword", annotationId, tooltip);
} else if (clickedRationale && node.contains(clickedRationale)) {
this.toggleAssistant("rationale", annotationId, tooltip);
}
});
});
}
async fetchAssistantData(assistantType, annotationId) {
const config = this.assistantConfig[assistantType];
if (!config) {
throw new Error(`Unknown assistant type: ${assistantType}`);
}
const params = new URLSearchParams({
annotationId: annotationId,
aiAssistant: assistantType
});
try {
const response = await fetch(`${config.apiEndpoint}?${params.toString()}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get("content-type");
let data = {}
if (contentType && contentType.includes("application/json")) {
data.res = await response.json();
data.type = "json";
} else {
data.res = await response.text();
data.type = "text";
}
return data;
} catch (error) {
console.error('Fetch error:', error);
throw error;
}
}
positionTooltip(tooltip) {
// Get the parent ai-help element to position relative to
const aiHelp = tooltip.closest('.ai-help');
console.log('[AIAssistant] positionTooltip called, aiHelp:', aiHelp);
if (!aiHelp) {
console.log('[AIAssistant] No aiHelp found, cannot position');
return;
}
const rect = aiHelp.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
console.log('[AIAssistant] aiHelp rect:', rect);
console.log('[AIAssistant] tooltip rect:', tooltipRect);
// Position below the button, centered
let top = rect.bottom + 8; // 8px gap below the button
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
// Keep tooltip within viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Adjust horizontal position if needed
if (left < 10) left = 10;
if (left + tooltipRect.width > viewportWidth - 10) {
left = viewportWidth - tooltipRect.width - 10;
}
// If tooltip would go below viewport, position above the button
if (top + tooltipRect.height > viewportHeight - 10) {
top = rect.top - tooltipRect.height - 8;
}
console.log('[AIAssistant] Setting tooltip position:', { top, left });
tooltip.style.top = `${top}px`;
tooltip.style.left = `${left}px`;
console.log('[AIAssistant] Tooltip computed style:', window.getComputedStyle(tooltip).cssText.substring(0, 200));
}
startLoading(tooltip, assistantType) {
if (!tooltip || assistantType == "keyword") return;
// this.closeOtherTooltips(tooltip);
const config = this.assistantConfig[assistantType];
tooltip.classList.add('active');
tooltip.classList.add(config.className);
tooltip.innerHTML = `
${config.loadingText}
`;
// Add close button event listener
const closeBtn = tooltip.querySelector('.tooltip-close');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.hideTooltip(tooltip);
});
}
this.activeTooltips.add(tooltip);
}
toggleAssistant(assistantType, annotationId, tooltip) {
console.log('[AIAssistant] toggleAssistant called:', { assistantType, annotationId, hasTooltip: !!tooltip });
// Check if this assistant type is supported for the current content
if (!this.shouldShowButton(assistantType)) {
const isImage = this.isImageContent();
const message = isImage
? `${assistantType} is not available for image content`
: `${assistantType} requires image content`;
console.warn(`[AIAssistant] ${message}`);
// Show a brief error message in the tooltip
if (tooltip) {
tooltip.classList.add('active');
tooltip.innerHTML = `
${message}
`;
const closeBtn = tooltip.querySelector('.tooltip-close');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.hideTooltip(tooltip);
});
}
this.activeTooltips.add(tooltip);
}
return;
}
if (assistantType == "keyword") {
console.log('[AIAssistant] Keyword clicked, checking spanManager:', {
spanManagerExists: !!window.spanManager,
inAiSpans: window.spanManager?.inAiSpans?.(annotationId)
});
if (window.spanManager.inAiSpans(annotationId)) {
window.spanManager.deleteOneAiSpan(annotationId);
return;
}
// const isCurrentlyActive = this.keywordHighlightStates.get(annotationId);
// if (isCurrentlyActive) {
// window.spanManager.renderSpans();
// this.keywordHighlightStates.set(annotationId, false);
// return;
// }
// this.keywordHighlightStates.set(annotationId, true);
}
if (tooltip.classList.contains('active') &&
tooltip.classList.contains(this.assistantConfig[assistantType].className)) {
this.hideTooltip(tooltip);
return;
}
this.getAiAssistantDefault(assistantType, annotationId, tooltip);
}
async getAiAssistantDefault(assistantType, annotationId, tooltip) {
console.log('[AIAssistant] getAiAssistantDefault called:', { assistantType, annotationId, hasTooltip: !!tooltip });
if (!tooltip) {
console.error('Tooltip element not found');
return;
}
// Track AI request
if (window.interactionTracker) {
window.interactionTracker.trackAIRequest(annotationId);
}
try {
this.startLoading(tooltip, assistantType);
console.log('[AIAssistant] Fetching data for:', assistantType);
const data = await this.fetchAssistantData(assistantType, annotationId);
console.log('[AIAssistant] Received data:', data);
// Track AI response
if (window.interactionTracker) {
const suggestions = this.extractSuggestionsFromData(data);
window.interactionTracker.trackAIResponse(annotationId, suggestions);
}
this.renderAssistant(tooltip, assistantType, data, annotationId);
} catch (error) {
console.error('Error getting AI assistant:', error);
this.showError(tooltip, assistantType);
}
}
/**
* Extract suggestion values from AI response data for tracking
*/
extractSuggestionsFromData(data) {
if (!data || !data.res) return [];
if (data.type === "json") {
const res = data.res;
const suggestions = [];
// Extract suggestive_choice if present
if (res.suggestive_choice) {
suggestions.push(res.suggestive_choice);
}
// Extract keywords if present
if (res.keywords && Array.isArray(res.keywords)) {
suggestions.push(...res.keywords.map(k => k.label || k.name || k));
}
return suggestions;
}
return [];
}
showError(tooltip, assistantType) {
const config = this.assistantConfig[assistantType];
tooltip.innerHTML = `
${config.errorText}
`;
// Add close button event listener
const closeBtn = tooltip.querySelector('.tooltip-close');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.hideTooltip(tooltip);
});
}
}
renderAssistant(tooltip, assistantType, data, annotationId) {
let content = '';
if (data.type === "json") {
switch (assistantType) {
case 'hint':
content = this.renderHint(data.res);
// Highlight the suggested label if present
if (data.res.suggestive_choice) {
this.highlightSuggestedLabel(annotationId, data.res.suggestive_choice);
}
break;
case 'keyword':
this.renderKeyword(data.res, annotationId);
return;
case 'rationale':
content = this.renderRationale(data.res);
break;
default:
content = '