Spaces:
Paused
Paused
| // 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 = ` | |
| <button class="tooltip-close" title="Close">×</button> | |
| <div class="assistant-content"> | |
| <div class="loading"> | |
| <span>${config.loadingText}</span> | |
| </div> | |
| </div> | |
| `; | |
| // 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 = ` | |
| <button class="tooltip-close" title="Close">×</button> | |
| <div class="assistant-content"> | |
| <div class="error"> | |
| <span>${message}</span> | |
| </div> | |
| </div> | |
| `; | |
| 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 = ` | |
| <button class="tooltip-close" title="Close">×</button> | |
| <div class="assistant-content"> | |
| <div class="error"> | |
| <span>${config.errorText}</span> | |
| </div> | |
| </div> | |
| `; | |
| // 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 = '<div>Unknown assistant type</div>'; | |
| } | |
| } else { | |
| content = data.res; | |
| } | |
| console.log('[AIAssistant] Setting tooltip content:', content); | |
| tooltip.innerHTML = ` | |
| <button class="tooltip-close" title="Close">×</button> | |
| <div class="assistant-content"> | |
| ${content} | |
| </div> | |
| `; | |
| console.log('[AIAssistant] Tooltip innerHTML set, tooltip element:', tooltip); | |
| // 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); | |
| console.log(this.activeTooltips); | |
| } | |
| renderHint(data) { | |
| return ` | |
| <div class="hint-section"> | |
| <div class="content-item"> | |
| <span class="label">Hint:</span> | |
| <span class="value">${data.hint || 'No hint available'}</span> | |
| </div> | |
| ${data.suggestive_choice ? ` | |
| <div class="content-item"> | |
| <span class="label">Suggestion:</span> | |
| <span class="value suggestion">${data.suggestive_choice}</span> | |
| </div> | |
| ` : ''} | |
| </div> | |
| `; | |
| } | |
| renderKeyword(data, annotationId) { | |
| console.log('[AIAssistant] renderKeyword called:', { data, annotationId }); | |
| // Get the text-content element (not instance-text) to match positioning calculations | |
| // #instance-text contains both #text-content and #span-overlays, which causes offset mismatches | |
| const textContent = document.getElementById('text-content'); | |
| if (!textContent) { | |
| console.error('[AIAssistant] text-content element not found'); | |
| return; | |
| } | |
| const text = textContent.textContent || textContent.innerText; | |
| console.log('[AIAssistant] Text content length:', text.length); | |
| // Parse the new label_keywords format | |
| const labelKeywords = data.label_keywords; | |
| if (!labelKeywords || !Array.isArray(labelKeywords)) { | |
| console.error('[AIAssistant] Invalid label_keywords format:', data); | |
| return; | |
| } | |
| // Get the annotation form to find the schema name | |
| // The form's id attribute is the schema name (e.g., "sentiment") | |
| const annotationForm = document.querySelector(`.annotation-form[data-annotation-id="${CSS.escape(String(annotationId))}"]`); | |
| const schemaName = annotationForm?.id || annotationForm?.dataset.schemaName || 'default'; | |
| // Find keywords in text and create highlight data | |
| const highlights = []; | |
| console.log('[AIAssistant] Processing label_keywords:', labelKeywords); | |
| labelKeywords.forEach(({ label, keywords }) => { | |
| if (!keywords || !Array.isArray(keywords)) { | |
| console.log('[AIAssistant] Skipping label with no keywords:', label); | |
| return; | |
| } | |
| console.log(`[AIAssistant] Finding keywords for label "${label}":`, keywords); | |
| keywords.forEach(keyword => { | |
| if (!keyword || typeof keyword !== 'string') return; | |
| // Find all occurrences of this keyword in the text (case-insensitive) | |
| const lowerText = text.toLowerCase(); | |
| const lowerKeyword = keyword.toLowerCase().trim(); | |
| let startIndex = 0; | |
| while ((startIndex = lowerText.indexOf(lowerKeyword, startIndex)) !== -1) { | |
| const foundText = text.substring(startIndex, startIndex + keyword.length); | |
| console.log(`[AIAssistant] Found "${foundText}" at position ${startIndex}-${startIndex + keyword.length}`); | |
| highlights.push({ | |
| label: label, | |
| start: startIndex, | |
| end: startIndex + keyword.length, | |
| text: foundText, | |
| schema: schemaName | |
| }); | |
| startIndex += keyword.length; | |
| } | |
| }); | |
| }); | |
| console.log('[AIAssistant] Total highlights found:', highlights.length, highlights); | |
| if (highlights.length === 0) { | |
| console.log('[AIAssistant] No keywords found in text'); | |
| return; | |
| } | |
| // Always use spanManager for keyword highlighting | |
| // This ensures consistent positioning and z-index with other overlays | |
| if (window.spanManager && typeof window.spanManager.insertAiKeywordHighlights === 'function') { | |
| window.spanManager.insertAiKeywordHighlights(highlights, annotationId); | |
| } else { | |
| console.error('[AIAssistant] spanManager.insertAiKeywordHighlights not available - cannot display keyword highlights'); | |
| } | |
| } | |
| /** | |
| * Get the color for a label from the loaded colors or schema colors | |
| */ | |
| getLabelColor(label, schemaName) { | |
| // Default colors for common labels | |
| const defaultColors = { | |
| 'positive': 'rgba(34, 197, 94, 0.8)', // green | |
| 'negative': 'rgba(239, 68, 68, 0.8)', // red | |
| 'neutral': 'rgba(156, 163, 175, 0.8)', // gray | |
| 'yes': 'rgba(34, 197, 94, 0.8)', // green | |
| 'no': 'rgba(239, 68, 68, 0.8)', // red | |
| 'maybe': 'rgba(245, 158, 11, 0.8)', // amber | |
| }; | |
| // 1. Try to get from AIAssistantManager's loaded colors | |
| if (this.colors && schemaName && this.colors[schemaName]) { | |
| const schemaColors = this.colors[schemaName]; | |
| if (schemaColors[label]) { | |
| return schemaColors[label]; | |
| } | |
| } | |
| // 2. Try to get from spanManager colors (if available) | |
| if (window.spanManager && window.spanManager.colors) { | |
| const schemaColors = window.spanManager.colors[schemaName]; | |
| if (schemaColors && schemaColors[label]) { | |
| const color = schemaColors[label]; | |
| if (color.startsWith('(')) { | |
| return `rgba${color.replace(')', ', 0.8)')}`; | |
| } | |
| return color; | |
| } | |
| } | |
| // 3. Try named default colors (case-insensitive) | |
| const lowerLabel = label.toLowerCase(); | |
| if (defaultColors[lowerLabel]) { | |
| return defaultColors[lowerLabel]; | |
| } | |
| // 4. Fallback to amber | |
| return 'rgba(245, 158, 11, 0.8)'; | |
| } | |
| renderRationale(data) { | |
| console.log('[AIAssistant] renderRationale called with data:', data); | |
| console.log('[AIAssistant] rationales:', data.rationales); | |
| // Handle error responses | |
| if (data.error) { | |
| return ` | |
| <div class="rationale-section"> | |
| <div class="content-item"> | |
| <span class="label">Error:</span> | |
| <span class="value">${this.escapeHtml(data.error)}</span> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| const rationales = data.rationales || []; | |
| if (rationales.length === 0) { | |
| return ` | |
| <div class="rationale-section"> | |
| <div class="content-item"> | |
| <span class="label">Rationale:</span> | |
| <span class="value">No rationales available</span> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| const rationaleItems = rationales.map(r => ` | |
| <div class="rationale-item"> | |
| <span class="rationale-label">${this.escapeHtml(r.label)}:</span> | |
| <span class="rationale-reasoning">${this.escapeHtml(r.reasoning)}</span> | |
| </div> | |
| `).join(''); | |
| return ` | |
| <div class="rationale-section"> | |
| <div class="rationale-header">Rationales for each label:</div> | |
| ${rationaleItems} | |
| </div> | |
| `; | |
| } | |
| escapeHtml(text) { | |
| if (!text) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| /** | |
| * Highlight a suggested label on the annotation form | |
| * @param {string} annotationId - The annotation ID | |
| * @param {string|number} suggestedLabel - The suggested label value | |
| */ | |
| highlightSuggestedLabel(annotationId, suggestedLabel) { | |
| aiDebugLog('[AIAssistant] highlightSuggestedLabel:', { annotationId, suggestedLabel }); | |
| // Clear any existing highlights for this annotation | |
| this.clearLabelHighlights(annotationId); | |
| // Find the annotation form with this ID | |
| const annotationForm = document.querySelector(`.annotation-form[data-annotation-id="${CSS.escape(String(annotationId))}"]`); | |
| if (!annotationForm) { | |
| console.warn('[AIAssistant] Annotation form not found for ID:', annotationId); | |
| return; | |
| } | |
| // Convert suggestedLabel to string for comparison | |
| const suggestedLabelStr = String(suggestedLabel).toLowerCase().trim(); | |
| // Find all label inputs (radio buttons, checkboxes) in the form | |
| const labelInputs = annotationForm.querySelectorAll('input[type="radio"], input[type="checkbox"]'); | |
| const highlightedElements = []; | |
| labelInputs.forEach(input => { | |
| // Get the label text - check value, id, or associated label element | |
| const inputValue = input.value?.toLowerCase().trim(); | |
| const inputId = input.id?.toLowerCase().trim(); | |
| // Also check the text of the associated label element | |
| const labelElement = annotationForm.querySelector(`label[for="${input.id}"]`); | |
| const labelText = labelElement?.textContent?.toLowerCase().trim(); | |
| // Check if this input matches the suggested label | |
| const isMatch = inputValue === suggestedLabelStr || | |
| inputId?.includes(suggestedLabelStr) || | |
| labelText?.includes(suggestedLabelStr) || | |
| suggestedLabelStr.includes(inputValue); | |
| if (isMatch) { | |
| aiDebugLog('[AIAssistant] Found matching label:', { inputValue, labelText, suggestedLabelStr }); | |
| // Add highlight class to the input | |
| input.classList.add('ai-suggested'); | |
| // Add highlight class to the associated label element | |
| if (labelElement) { | |
| labelElement.classList.add('ai-suggested-label'); | |
| // Add a sparkle/star indicator | |
| if (!labelElement.querySelector('.ai-suggestion-indicator')) { | |
| const indicator = document.createElement('span'); | |
| indicator.className = 'ai-suggestion-indicator'; | |
| indicator.innerHTML = ' ✨'; // Sparkle emoji | |
| indicator.title = 'AI Suggested'; | |
| indicator.style.marginLeft = '4px'; | |
| labelElement.appendChild(indicator); | |
| } | |
| highlightedElements.push(labelElement); | |
| } | |
| // Also highlight the parent container if it exists | |
| const parentOption = input.closest('.shadcn-radio-option, .shadcn-checkbox-option, .shadcn-span-option, .option-item'); | |
| if (parentOption) { | |
| parentOption.classList.add('ai-suggested-option'); | |
| highlightedElements.push(parentOption); | |
| } | |
| highlightedElements.push(input); | |
| } | |
| }); | |
| // Store the highlighted elements for later cleanup | |
| if (highlightedElements.length > 0) { | |
| this.highlightedLabels.set(annotationId, highlightedElements); | |
| console.log(`[AIAssistant] Highlighted ${highlightedElements.length} elements for annotation ${annotationId}`); | |
| } | |
| } | |
| /** | |
| * Clear label highlights for a specific annotation | |
| * @param {string} annotationId - The annotation ID (optional, clears all if not provided) | |
| */ | |
| clearLabelHighlights(annotationId = null) { | |
| if (annotationId) { | |
| const elements = this.highlightedLabels.get(annotationId); | |
| if (elements) { | |
| elements.forEach(el => { | |
| el.classList.remove('ai-suggested', 'ai-suggested-label', 'ai-suggested-option'); | |
| const indicator = el.querySelector('.ai-suggestion-indicator'); | |
| if (indicator) { | |
| indicator.remove(); | |
| } | |
| }); | |
| this.highlightedLabels.delete(annotationId); | |
| } | |
| } else { | |
| // Clear all highlights | |
| this.highlightedLabels.forEach((elements, id) => { | |
| elements.forEach(el => { | |
| el.classList.remove('ai-suggested', 'ai-suggested-label', 'ai-suggested-option'); | |
| const indicator = el.querySelector('.ai-suggestion-indicator'); | |
| if (indicator) { | |
| indicator.remove(); | |
| } | |
| }); | |
| }); | |
| this.highlightedLabels.clear(); | |
| } | |
| } | |
| async getAiAssistantName() { | |
| console.log('[AIAssistant] getAiAssistantName() called'); | |
| const forms = document.querySelectorAll('.annotation-form'); | |
| console.log('[AIAssistant] Found annotation forms:', forms.length); | |
| forms.forEach(async (node) => { | |
| const aiHelp = node.querySelector(".ai-help"); | |
| const annotationId = node.getAttribute("data-annotation-id"); | |
| aiDebugLog('[AIAssistant] Looking for .ai-help in form:', node.id, 'annotationId:', annotationId, '- Found:', aiHelp !== null); | |
| // Skip if no ai-help element exists (e.g., video/audio annotation forms) | |
| if (!aiHelp) { | |
| aiDebugLog('[AIAssistant] No .ai-help element found, skipping'); | |
| return; | |
| } | |
| // Skip if AI assistant buttons are already loaded (check for both spellings) | |
| const existingButtons = aiHelp.querySelector('.ai-assistant-containter, .ai-assistant-container'); | |
| if (existingButtons) { | |
| aiDebugLog('[AIAssistant] AI buttons already loaded, skipping'); | |
| return; | |
| } | |
| // Mark as loading to prevent duplicate requests | |
| if (aiHelp.dataset.aiLoading === 'true') { | |
| aiDebugLog('[AIAssistant] Already loading, skipping duplicate request'); | |
| return; | |
| } | |
| aiHelp.dataset.aiLoading = 'true'; | |
| aiDebugLog('[AIAssistant] Fetching AI assistant for annotationId:', annotationId); | |
| const params = new URLSearchParams({ | |
| annotationId: annotationId | |
| }); | |
| try { | |
| const response = await fetch(`/api/ai_assistant?${params.toString()}`, { | |
| method: 'GET', | |
| headers: { 'Content-Type': 'application/json' } | |
| }); | |
| const data = await response.text(); | |
| aiDebugLog('[AIAssistant] Response received, data length:', data?.length); | |
| if (!data || !data.trim()) { | |
| aiDebugLog('[AIAssistant] Empty response, not inserting'); | |
| aiHelp.dataset.aiLoading = 'false'; | |
| return; | |
| } | |
| // Double-check no buttons were added while we were fetching | |
| const buttonsAddedDuringFetch = aiHelp.querySelector('.ai-assistant-containter, .ai-assistant-container'); | |
| if (buttonsAddedDuringFetch) { | |
| aiDebugLog('[AIAssistant] Buttons were added during fetch, skipping'); | |
| aiHelp.dataset.aiLoading = 'false'; | |
| return; | |
| } | |
| aiDebugLog('[AIAssistant] Inserting HTML and removing "none" class'); | |
| aiHelp.classList.remove("none"); | |
| // Clear any existing content before inserting (except tooltip) | |
| const tooltip = aiHelp.querySelector('.tooltip'); | |
| const tooltipHTML = tooltip ? tooltip.outerHTML : '<div class="tooltip"></div>'; | |
| aiHelp.innerHTML = data + tooltipHTML; | |
| aiDebugLog('[AIAssistant] HTML inserted successfully'); | |
| } catch (error) { | |
| console.error('[AIAssistant] Error fetching AI assistant:', error); | |
| if (aiHelp) { | |
| aiHelp.innerHTML = '<span class="error">Error loading AI assistant</span><div class="tooltip"></div>'; | |
| } | |
| } finally { | |
| aiHelp.dataset.aiLoading = 'false'; | |
| } | |
| }); | |
| } | |
| // Utility methods | |
| isTooltipActive(tooltip) { | |
| return tooltip?.classList.contains('active') || false; | |
| } | |
| hideTooltip(tooltip) { | |
| if (!tooltip) return; | |
| tooltip.className = "tooltip"; | |
| tooltip.innerHTML = ''; | |
| this.activeTooltips.delete(tooltip); | |
| } | |
| closeAllTooltips() { | |
| this.activeTooltips.forEach(tooltip => { | |
| this.hideTooltip(tooltip); | |
| }); | |
| // Clear AI spans if spanManager exists | |
| if (window.spanManager && typeof window.spanManager.clearAiSpans === 'function') { | |
| window.spanManager.clearAiSpans(); | |
| } | |
| // Clear label highlights | |
| this.clearLabelHighlights(); | |
| } | |
| closeOtherTooltips(currentTooltip) { | |
| this.activeTooltips.forEach(tooltip => { | |
| if (currentTooltip !== tooltip) { | |
| this.hideTooltip(tooltip); | |
| } | |
| }); | |
| } | |
| } |