Spaces:
Paused
Paused
| /** | |
| * Entity Linking Module | |
| * | |
| * Provides functionality to link span annotations to external knowledge bases | |
| * like Wikidata and UMLS. Features include: | |
| * - Search modal for finding KB entities | |
| * - Entity details popup on hover | |
| * - Auto-search when spans are created | |
| * - Visual indicators for linked spans | |
| */ | |
| (function() { | |
| 'use strict'; | |
| // Module state | |
| const EntityLinking = { | |
| initialized: false, | |
| configuredKBs: [], | |
| currentSpanId: null, | |
| currentInstanceId: null, | |
| searchTimeout: null, | |
| modal: null, | |
| config: null, | |
| // Track currently linked entities for the open span | |
| currentLinkedEntities: [], // Array of {kb_id, kb_source, kb_label} | |
| multiSelect: false // Whether multi-select is enabled | |
| }; | |
| /** | |
| * Initialize the entity linking module | |
| */ | |
| function init() { | |
| if (EntityLinking.initialized) { | |
| console.log('[EntityLinking] Already initialized'); | |
| return; | |
| } | |
| console.log('[EntityLinking] Initializing...'); | |
| // Check if entity linking is enabled for any schema | |
| const schemas = document.querySelectorAll('[data-entity-linking]'); | |
| if (schemas.length === 0) { | |
| console.log('[EntityLinking] No schemas with entity linking enabled'); | |
| return; | |
| } | |
| // Parse config from the first schema (they should all be the same) | |
| try { | |
| const configStr = schemas[0].getAttribute('data-entity-linking'); | |
| EntityLinking.config = JSON.parse(configStr); | |
| EntityLinking.multiSelect = EntityLinking.config.multi_select || false; | |
| console.log('[EntityLinking] Config:', EntityLinking.config); | |
| console.log('[EntityLinking] Multi-select enabled:', EntityLinking.multiSelect); | |
| } catch (e) { | |
| console.error('[EntityLinking] Failed to parse config:', e); | |
| return; | |
| } | |
| // Create the search modal | |
| createSearchModal(); | |
| // Fetch configured knowledge bases | |
| fetchConfiguredKBs(); | |
| // Set up event listeners | |
| setupEventListeners(); | |
| EntityLinking.initialized = true; | |
| console.log('[EntityLinking] Initialized successfully'); | |
| } | |
| /** | |
| * Fetch the list of configured knowledge bases from the server | |
| */ | |
| async function fetchConfiguredKBs() { | |
| try { | |
| const response = await fetch('/api/entity_linking/configured_kbs'); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| EntityLinking.configuredKBs = data.knowledge_bases || []; | |
| console.log('[EntityLinking] Configured KBs:', EntityLinking.configuredKBs); | |
| // Update the KB selector in the modal | |
| updateKBSelector(); | |
| } | |
| } catch (e) { | |
| console.error('[EntityLinking] Failed to fetch configured KBs:', e); | |
| } | |
| } | |
| /** | |
| * Update the KB selector dropdown with available knowledge bases | |
| */ | |
| function updateKBSelector() { | |
| const selector = document.getElementById('el-kb-selector'); | |
| if (!selector) return; | |
| selector.innerHTML = ''; | |
| EntityLinking.configuredKBs.forEach(kb => { | |
| const option = document.createElement('option'); | |
| option.value = kb.name; | |
| option.textContent = `${kb.name} (${kb.type})`; | |
| selector.appendChild(option); | |
| }); | |
| } | |
| /** | |
| * Create the entity linking search modal | |
| */ | |
| function createSearchModal() { | |
| // Check if modal already exists | |
| if (document.getElementById('entity-linking-modal')) { | |
| EntityLinking.modal = document.getElementById('entity-linking-modal'); | |
| return; | |
| } | |
| const modal = document.createElement('div'); | |
| modal.id = 'entity-linking-modal'; | |
| modal.className = 'el-modal'; | |
| modal.innerHTML = ` | |
| <div class="el-modal-content"> | |
| <div class="el-modal-header"> | |
| <h3>Link to Knowledge Base Entity</h3> | |
| <button class="el-close-btn" title="Close">×</button> | |
| </div> | |
| <div class="el-modal-body"> | |
| <div class="el-span-info"> | |
| <strong>Selected text:</strong> | |
| <span id="el-selected-text" class="el-selected-text"></span> | |
| </div> | |
| <div class="el-search-container"> | |
| <select id="el-kb-selector" class="el-kb-selector"> | |
| <option value="">Select Knowledge Base...</option> | |
| </select> | |
| <input type="text" id="el-search-input" class="el-search-input" | |
| placeholder="Search for entity..."> | |
| <button id="el-search-btn" class="el-search-btn">Search</button> | |
| </div> | |
| <div id="el-loading" class="el-loading" style="display: none;"> | |
| <span class="el-spinner"></span> Searching... | |
| </div> | |
| <div id="el-results" class="el-results"></div> | |
| <div id="el-current-link" class="el-current-link" style="display: none;"> | |
| <strong>Currently linked to:</strong> | |
| <div id="el-current-entity"></div> | |
| <button id="el-remove-link" class="el-remove-link-btn">Remove Link</button> | |
| </div> | |
| </div> | |
| <div class="el-modal-footer"> | |
| <button id="el-save-btn" class="el-save-btn" style="display: none;">Save Selection</button> | |
| <button id="el-cancel-btn" class="el-cancel-btn">Cancel</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(modal); | |
| EntityLinking.modal = modal; | |
| // Set up modal event listeners | |
| modal.querySelector('.el-close-btn').addEventListener('click', closeModal); | |
| modal.querySelector('#el-cancel-btn').addEventListener('click', closeModal); | |
| modal.querySelector('#el-save-btn').addEventListener('click', saveMultiSelect); | |
| modal.querySelector('#el-search-btn').addEventListener('click', performSearch); | |
| modal.querySelector('#el-search-input').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') { | |
| performSearch(); | |
| } | |
| }); | |
| modal.querySelector('#el-search-input').addEventListener('input', debounceSearch); | |
| modal.querySelector('#el-remove-link').addEventListener('click', removeCurrentLink); | |
| // Close on backdrop click | |
| modal.addEventListener('click', (e) => { | |
| if (e.target === modal) { | |
| closeModal(); | |
| } | |
| }); | |
| } | |
| /** | |
| * Set up event listeners for span interactions | |
| */ | |
| function setupEventListeners() { | |
| // Listen for span creation events | |
| document.addEventListener('spanCreated', handleSpanCreated); | |
| // Add click handlers to existing spans with link icons | |
| addLinkIconsToSpans(); | |
| // Listen for span hover to show entity info | |
| document.addEventListener('mouseover', handleSpanHover); | |
| document.addEventListener('mouseout', handleSpanHoverOut); | |
| } | |
| /** | |
| * Get the set of schema names that have entity linking enabled | |
| */ | |
| function getSchemasWithEntityLinking() { | |
| const schemas = new Set(); | |
| document.querySelectorAll('[data-entity-linking]').forEach(el => { | |
| // The element with data-entity-linking is usually a form or container | |
| // with a data-schema-name attribute, or we can extract from its structure | |
| const schemaName = el.getAttribute('data-schema-name') || | |
| el.querySelector('[name^="span_label:::"]')?.name?.split(':::')[1] || | |
| el.id?.replace('annotation-form-', ''); | |
| if (schemaName) { | |
| schemas.add(schemaName); | |
| } | |
| }); | |
| return schemas; | |
| } | |
| /** | |
| * Add link icons to all span overlays (only for schemas with entity linking enabled) | |
| */ | |
| function addLinkIconsToSpans() { | |
| const enabledSchemas = getSchemasWithEntityLinking(); | |
| if (enabledSchemas.size === 0) { | |
| return; | |
| } | |
| // Look for span-overlay-pure (created by span-core.js) instead of span-highlight | |
| const overlays = document.querySelectorAll('.span-overlay-pure'); | |
| overlays.forEach(overlay => { | |
| // Only add icon if the span's schema has entity linking enabled | |
| const spanSchema = overlay.dataset.schema; | |
| if (spanSchema && enabledSchemas.has(spanSchema) && !overlay.querySelector('.el-link-icon')) { | |
| addLinkIconToSpan(overlay); | |
| } | |
| }); | |
| } | |
| /** | |
| * Add a link icon to a specific span overlay | |
| */ | |
| function addLinkIconToSpan(overlay) { | |
| // Double-check that this span's schema has entity linking enabled | |
| const enabledSchemas = getSchemasWithEntityLinking(); | |
| const spanSchema = overlay.dataset.schema; | |
| if (spanSchema && !enabledSchemas.has(spanSchema)) { | |
| return; // Skip - this schema doesn't have entity linking | |
| } | |
| // Find the controls container where label and delete button are | |
| const controlsContainer = overlay.querySelector('.span-controls'); | |
| if (!controlsContainer) { | |
| console.debug('[EntityLinking] No controls container found for overlay'); | |
| return; | |
| } | |
| const icon = document.createElement('button'); | |
| icon.className = 'el-link-icon'; | |
| icon.type = 'button'; | |
| icon.innerHTML = overlay.classList.contains('has-entity-link') ? | |
| '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>' : | |
| '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>'; | |
| icon.title = overlay.classList.contains('has-entity-link') ? | |
| 'Edit entity link' : 'Link to knowledge base'; | |
| // Style the button to match the delete button | |
| icon.style.backgroundColor = 'rgba(99, 102, 241, 0.9)'; | |
| icon.style.color = 'white'; | |
| icon.style.border = 'none'; | |
| icon.style.borderRadius = '50%'; | |
| icon.style.width = '16px'; | |
| icon.style.height = '16px'; | |
| icon.style.minWidth = '16px'; | |
| icon.style.minHeight = '16px'; | |
| icon.style.padding = '0'; | |
| icon.style.margin = '0'; | |
| icon.style.cursor = 'pointer'; | |
| icon.style.display = 'flex'; | |
| icon.style.alignItems = 'center'; | |
| icon.style.justifyContent = 'center'; | |
| icon.style.flexShrink = '0'; | |
| icon.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| openModal(overlay); | |
| }); | |
| // Insert before the delete button | |
| const deleteBtn = controlsContainer.querySelector('.span-delete-btn'); | |
| if (deleteBtn) { | |
| controlsContainer.insertBefore(icon, deleteBtn); | |
| } else { | |
| controlsContainer.appendChild(icon); | |
| } | |
| } | |
| /** | |
| * Handle span creation event | |
| */ | |
| function handleSpanCreated(event) { | |
| const span = event.detail.span; | |
| const spanElement = event.detail.element; | |
| if (!spanElement) return; | |
| // Add link icon | |
| addLinkIconToSpan(spanElement); | |
| // Auto-search if enabled | |
| if (EntityLinking.config && EntityLinking.config.auto_search) { | |
| openModal(spanElement); | |
| } | |
| } | |
| /** | |
| * Handle hover over a span with entity link | |
| */ | |
| function handleSpanHover(event) { | |
| const span = event.target.closest('.span-highlight.has-entity-link'); | |
| if (!span) return; | |
| const kbId = span.getAttribute('data-kb-id'); | |
| const kbSource = span.getAttribute('data-kb-source'); | |
| const kbLabel = span.getAttribute('data-kb-label'); | |
| if (!kbId || !kbSource) return; | |
| // Create tooltip | |
| showEntityTooltip(span, kbId, kbSource, kbLabel); | |
| } | |
| /** | |
| * Handle hover out from span | |
| */ | |
| function handleSpanHoverOut(event) { | |
| const span = event.target.closest('.span-highlight.has-entity-link'); | |
| if (!span) return; | |
| hideEntityTooltip(); | |
| } | |
| /** | |
| * Show entity tooltip on hover | |
| */ | |
| function showEntityTooltip(span, kbId, kbSource, kbLabel) { | |
| // Remove existing tooltip | |
| hideEntityTooltip(); | |
| const tooltip = document.createElement('div'); | |
| tooltip.id = 'el-entity-tooltip'; | |
| tooltip.className = 'el-entity-tooltip'; | |
| tooltip.innerHTML = ` | |
| <div class="el-tooltip-header"> | |
| <span class="el-tooltip-kb">${escapeHtml(kbSource)}</span> | |
| <span class="el-tooltip-id">${escapeHtml(kbId)}</span> | |
| </div> | |
| <div class="el-tooltip-label">${escapeHtml(kbLabel || 'Loading...')}</div> | |
| `; | |
| // Position tooltip near the span | |
| const rect = span.getBoundingClientRect(); | |
| tooltip.style.position = 'fixed'; | |
| tooltip.style.left = `${rect.left}px`; | |
| tooltip.style.top = `${rect.bottom + 5}px`; | |
| document.body.appendChild(tooltip); | |
| // Fetch full entity details if we only have ID | |
| if (!kbLabel) { | |
| fetchEntityDetails(kbId, kbSource, tooltip); | |
| } | |
| } | |
| /** | |
| * Hide entity tooltip | |
| */ | |
| function hideEntityTooltip() { | |
| const tooltip = document.getElementById('el-entity-tooltip'); | |
| if (tooltip) { | |
| tooltip.remove(); | |
| } | |
| } | |
| /** | |
| * Fetch entity details for tooltip | |
| */ | |
| async function fetchEntityDetails(kbId, kbSource, tooltip) { | |
| try { | |
| const response = await fetch(`/api/entity_linking/entity/${kbSource}/${kbId}`); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| const entity = data.entity; | |
| if (tooltip && document.body.contains(tooltip)) { | |
| tooltip.querySelector('.el-tooltip-label').textContent = entity.label; | |
| if (entity.description) { | |
| const desc = document.createElement('div'); | |
| desc.className = 'el-tooltip-desc'; | |
| desc.textContent = entity.description; | |
| tooltip.appendChild(desc); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.error('[EntityLinking] Failed to fetch entity details:', e); | |
| } | |
| } | |
| /** | |
| * Open the entity linking modal for a span | |
| */ | |
| function openModal(spanElement) { | |
| if (!EntityLinking.modal) return; | |
| // Get span info from data attributes | |
| EntityLinking.currentSpanId = spanElement.getAttribute('data-annotation-id'); | |
| EntityLinking.currentInstanceId = document.getElementById('instance_id')?.value; | |
| // Get selected text from the original text using start/end offsets | |
| const start = parseInt(spanElement.getAttribute('data-start'), 10); | |
| const end = parseInt(spanElement.getAttribute('data-end'), 10); | |
| let selectedText = ''; | |
| // Try to get the original text from text-content element | |
| const textContent = document.getElementById('text-content'); | |
| if (textContent && !isNaN(start) && !isNaN(end)) { | |
| const originalText = textContent.getAttribute('data-original-text') || textContent.textContent; | |
| selectedText = originalText.substring(start, end); | |
| } | |
| // Fallback: try to get text from the span label | |
| if (!selectedText) { | |
| const label = spanElement.querySelector('.span-label'); | |
| selectedText = label ? label.textContent : spanElement.getAttribute('data-label') || ''; | |
| } | |
| document.getElementById('el-selected-text').textContent = selectedText; | |
| document.getElementById('el-search-input').value = selectedText; | |
| // Check for existing link(s) | |
| const kbId = spanElement.getAttribute('data-kb-id'); | |
| const kbSource = spanElement.getAttribute('data-kb-source'); | |
| const kbLabel = spanElement.getAttribute('data-kb-label'); | |
| // Reset linked entities list | |
| EntityLinking.currentLinkedEntities = []; | |
| if (kbId && kbSource) { | |
| // For backwards compatibility, single link stored as attributes | |
| // Future: could store multiple as JSON in data-kb-entities | |
| EntityLinking.currentLinkedEntities.push({ | |
| kb_id: kbId, | |
| kb_source: kbSource, | |
| kb_label: kbLabel | |
| }); | |
| showCurrentLink(kbId, kbSource, kbLabel); | |
| } else { | |
| document.getElementById('el-current-link').style.display = 'none'; | |
| } | |
| // Clear previous results | |
| document.getElementById('el-results').innerHTML = ''; | |
| document.getElementById('el-loading').style.display = 'none'; | |
| // Show/hide save button based on multi-select mode | |
| const saveBtn = document.getElementById('el-save-btn'); | |
| if (saveBtn) { | |
| saveBtn.style.display = EntityLinking.multiSelect ? 'inline-block' : 'none'; | |
| } | |
| // Show modal | |
| EntityLinking.modal.style.display = 'flex'; | |
| document.getElementById('el-search-input').focus(); | |
| // Auto-search with current text | |
| if (selectedText && EntityLinking.configuredKBs.length > 0) { | |
| const selector = document.getElementById('el-kb-selector'); | |
| if (!selector.value && EntityLinking.configuredKBs[0]) { | |
| selector.value = EntityLinking.configuredKBs[0].name; | |
| } | |
| performSearch(); | |
| } | |
| } | |
| /** | |
| * Save multi-select entities | |
| */ | |
| async function saveMultiSelect() { | |
| if (!EntityLinking.currentSpanId || !EntityLinking.currentInstanceId) { | |
| console.error('[EntityLinking] No span selected for multi-select save'); | |
| return; | |
| } | |
| // Get the primary entity (first one) for backwards compatibility | |
| const primaryEntity = EntityLinking.currentLinkedEntities[0]; | |
| if (!primaryEntity) { | |
| // No entities selected - remove link | |
| await removeCurrentLink(); | |
| return; | |
| } | |
| try { | |
| const requestBody = { | |
| instance_id: EntityLinking.currentInstanceId, | |
| span_id: EntityLinking.currentSpanId, | |
| kb_id: primaryEntity.kb_id, | |
| kb_source: primaryEntity.kb_source, | |
| kb_label: primaryEntity.kb_label, | |
| // Include all linked entities for future multi-select support | |
| linked_entities: EntityLinking.currentLinkedEntities | |
| }; | |
| const response = await fetch('/api/entity_linking/update_span', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (response.ok) { | |
| // Update the span overlay element | |
| const spanElement = document.querySelector( | |
| `.span-overlay-pure[data-annotation-id="${CSS.escape(EntityLinking.currentSpanId)}"]` | |
| ); | |
| if (spanElement) { | |
| spanElement.setAttribute('data-kb-id', primaryEntity.kb_id); | |
| spanElement.setAttribute('data-kb-source', primaryEntity.kb_source); | |
| spanElement.setAttribute('data-kb-label', primaryEntity.kb_label || ''); | |
| spanElement.classList.add('has-entity-link'); | |
| // Update link icon | |
| const icon = spanElement.querySelector('.el-link-icon'); | |
| if (icon) { | |
| icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>'; | |
| icon.title = 'Edit entity link'; | |
| } | |
| } | |
| closeModal(); | |
| console.log('[EntityLinking] Multi-select saved:', EntityLinking.currentLinkedEntities.length, 'entities'); | |
| } | |
| } catch (e) { | |
| console.error('[EntityLinking] Error saving multi-select:', e); | |
| } | |
| } | |
| /** | |
| * Show current entity link in modal | |
| */ | |
| function showCurrentLink(kbId, kbSource, kbLabel) { | |
| const container = document.getElementById('el-current-link'); | |
| const entityDiv = document.getElementById('el-current-entity'); | |
| entityDiv.innerHTML = ` | |
| <span class="el-current-kb">${escapeHtml(kbSource)}</span>: | |
| <span class="el-current-id">${escapeHtml(kbId)}</span> | |
| ${kbLabel ? `<br><span class="el-current-label">${escapeHtml(kbLabel)}</span>` : ''} | |
| `; | |
| container.style.display = 'block'; | |
| } | |
| /** | |
| * Close the modal | |
| */ | |
| function closeModal() { | |
| if (EntityLinking.modal) { | |
| EntityLinking.modal.style.display = 'none'; | |
| } | |
| EntityLinking.currentSpanId = null; | |
| EntityLinking.currentInstanceId = null; | |
| } | |
| /** | |
| * Debounced search | |
| */ | |
| function debounceSearch() { | |
| clearTimeout(EntityLinking.searchTimeout); | |
| EntityLinking.searchTimeout = setTimeout(performSearch, 300); | |
| } | |
| /** | |
| * Perform entity search with multi-word support | |
| * For multi-word queries, also searches individual words and combines results | |
| */ | |
| async function performSearch() { | |
| const query = document.getElementById('el-search-input').value.trim(); | |
| const kbName = document.getElementById('el-kb-selector').value; | |
| if (!query || !kbName) { | |
| return; | |
| } | |
| const loading = document.getElementById('el-loading'); | |
| const results = document.getElementById('el-results'); | |
| loading.style.display = 'flex'; | |
| results.innerHTML = ''; | |
| try { | |
| // Split query into words for multi-word search | |
| const words = query.split(/\s+/).filter(w => w.length > 2); | |
| const searches = [query]; // Always search the full query first | |
| // For multi-word queries, also search individual significant words | |
| if (words.length > 1) { | |
| words.forEach(word => { | |
| if (!searches.includes(word)) { | |
| searches.push(word); | |
| } | |
| }); | |
| } | |
| // Perform searches in parallel (limit to 3 searches) | |
| const searchPromises = searches.slice(0, 3).map(q => | |
| fetch(`/api/entity_linking/search?q=${encodeURIComponent(q)}&kb=${encodeURIComponent(kbName)}&limit=5`) | |
| .then(r => r.ok ? r.json() : { results: [] }) | |
| .catch(() => ({ results: [] })) | |
| ); | |
| const searchResults = await Promise.all(searchPromises); | |
| // Combine and deduplicate results | |
| const seenIds = new Set(); | |
| const allResults = []; | |
| searchResults.forEach(data => { | |
| (data.results || []).forEach(entity => { | |
| if (!seenIds.has(entity.entity_id)) { | |
| seenIds.add(entity.entity_id); | |
| allResults.push(entity); | |
| } | |
| }); | |
| }); | |
| // Limit to 10 results | |
| displayResults(allResults.slice(0, 10)); | |
| } catch (e) { | |
| console.error('[EntityLinking] Search error:', e); | |
| results.innerHTML = '<div class="el-error">Search failed. Please try again.</div>'; | |
| } finally { | |
| loading.style.display = 'none'; | |
| } | |
| } | |
| /** | |
| * Check if an entity is currently linked | |
| */ | |
| function isEntityLinked(entityId, kbSource) { | |
| return EntityLinking.currentLinkedEntities.some( | |
| e => e.kb_id === entityId && e.kb_source === kbSource | |
| ); | |
| } | |
| /** | |
| * Display search results | |
| */ | |
| function displayResults(entities) { | |
| const results = document.getElementById('el-results'); | |
| if (!entities || entities.length === 0) { | |
| results.innerHTML = '<div class="el-no-results">No entities found.</div>'; | |
| return; | |
| } | |
| const multiSelect = EntityLinking.multiSelect; | |
| results.innerHTML = entities.map(entity => { | |
| const isLinked = isEntityLinked(entity.entity_id, entity.kb_source); | |
| const linkedClass = isLinked ? 'el-result-item-linked' : ''; | |
| const linkedBadge = isLinked ? '<span class="el-linked-badge">✓ Currently Linked</span>' : ''; | |
| const checkbox = multiSelect ? | |
| `<input type="checkbox" class="el-result-checkbox" ${isLinked ? 'checked' : ''}>` : ''; | |
| return ` | |
| <div class="el-result-item ${linkedClass}" data-entity-id="${entity.entity_id}" | |
| data-kb-source="${entity.kb_source}" data-label="${escapeHtml(entity.label)}"> | |
| <div class="el-result-header"> | |
| ${checkbox} | |
| <span class="el-result-label">${escapeHtml(entity.label)}</span> | |
| <span class="el-result-id">${entity.entity_id}</span> | |
| ${linkedBadge} | |
| </div> | |
| ${entity.description ? `<div class="el-result-desc">${escapeHtml(entity.description)}</div>` : ''} | |
| ${entity.aliases && entity.aliases.length > 0 ? | |
| `<div class="el-result-aliases">Also: ${entity.aliases.slice(0, 3).map(a => escapeHtml(a)).join(', ')}</div>` : ''} | |
| ${entity.url ? `<a href="${entity.url}" target="_blank" class="el-result-link">View in KB</a>` : ''} | |
| </div> | |
| `; | |
| }).join(''); | |
| // Add click handlers | |
| results.querySelectorAll('.el-result-item').forEach(item => { | |
| if (multiSelect) { | |
| // In multi-select mode, clicking toggles checkbox | |
| item.addEventListener('click', (e) => { | |
| if (e.target.tagName !== 'A') { // Don't toggle on link clicks | |
| const checkbox = item.querySelector('.el-result-checkbox'); | |
| if (checkbox && e.target !== checkbox) { | |
| checkbox.checked = !checkbox.checked; | |
| } | |
| updateMultiSelectState(item, checkbox?.checked); | |
| } | |
| }); | |
| } else { | |
| // In single-select mode, clicking selects immediately | |
| item.addEventListener('click', (e) => { | |
| if (e.target.tagName !== 'A') { | |
| selectEntity(item); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| /** | |
| * Update multi-select state when checkbox changes | |
| */ | |
| function updateMultiSelectState(item, isSelected) { | |
| const entityId = item.getAttribute('data-entity-id'); | |
| const kbSource = item.getAttribute('data-kb-source'); | |
| const label = item.getAttribute('data-label'); | |
| if (isSelected) { | |
| // Add to linked entities if not already there | |
| if (!isEntityLinked(entityId, kbSource)) { | |
| EntityLinking.currentLinkedEntities.push({ | |
| kb_id: entityId, | |
| kb_source: kbSource, | |
| kb_label: label | |
| }); | |
| } | |
| item.classList.add('el-result-item-linked'); | |
| } else { | |
| // Remove from linked entities | |
| EntityLinking.currentLinkedEntities = EntityLinking.currentLinkedEntities.filter( | |
| e => !(e.kb_id === entityId && e.kb_source === kbSource) | |
| ); | |
| item.classList.remove('el-result-item-linked'); | |
| } | |
| // Update the badge | |
| const badge = item.querySelector('.el-linked-badge'); | |
| if (isSelected && !badge) { | |
| const header = item.querySelector('.el-result-header'); | |
| const newBadge = document.createElement('span'); | |
| newBadge.className = 'el-linked-badge'; | |
| newBadge.textContent = '✓ Currently Linked'; | |
| header.appendChild(newBadge); | |
| } else if (!isSelected && badge) { | |
| badge.remove(); | |
| } | |
| } | |
| /** | |
| * Select an entity from search results | |
| */ | |
| async function selectEntity(item) { | |
| const entityId = item.getAttribute('data-entity-id'); | |
| const kbSource = item.getAttribute('data-kb-source'); | |
| const label = item.getAttribute('data-label'); | |
| console.log('[EntityLinking] selectEntity called with:', { entityId, kbSource, label }); | |
| console.log('[EntityLinking] Current state:', { | |
| spanId: EntityLinking.currentSpanId, | |
| instanceId: EntityLinking.currentInstanceId | |
| }); | |
| if (!EntityLinking.currentSpanId || !EntityLinking.currentInstanceId) { | |
| console.error('[EntityLinking] No span selected - spanId:', EntityLinking.currentSpanId, 'instanceId:', EntityLinking.currentInstanceId); | |
| alert('Error: No span selected. Please try again.'); | |
| return; | |
| } | |
| try { | |
| const requestBody = { | |
| instance_id: EntityLinking.currentInstanceId, | |
| span_id: EntityLinking.currentSpanId, | |
| kb_id: entityId, | |
| kb_source: kbSource, | |
| kb_label: label | |
| }; | |
| console.log('[EntityLinking] Sending request:', requestBody); | |
| const response = await fetch('/api/entity_linking/update_span', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| console.log('[EntityLinking] Response status:', response.status); | |
| if (response.ok) { | |
| // Update the span overlay element | |
| const spanElement = document.querySelector( | |
| `.span-overlay-pure[data-annotation-id="${CSS.escape(EntityLinking.currentSpanId)}"]` | |
| ); | |
| if (spanElement) { | |
| spanElement.setAttribute('data-kb-id', entityId); | |
| spanElement.setAttribute('data-kb-source', kbSource); | |
| spanElement.setAttribute('data-kb-label', label); | |
| spanElement.classList.add('has-entity-link'); | |
| // Update link icon | |
| const icon = spanElement.querySelector('.el-link-icon'); | |
| if (icon) { | |
| icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>'; | |
| icon.title = 'Edit entity link'; | |
| } | |
| } else { | |
| console.warn('[EntityLinking] Could not find span element to update'); | |
| } | |
| closeModal(); | |
| console.log('[EntityLinking] Entity link saved:', entityId); | |
| } else { | |
| const errorData = await response.json().catch(() => ({})); | |
| console.error('[EntityLinking] Failed to save entity link:', errorData); | |
| } | |
| } catch (e) { | |
| console.error('[EntityLinking] Error saving entity link:', e); | |
| } | |
| } | |
| /** | |
| * Remove the current entity link | |
| */ | |
| async function removeCurrentLink() { | |
| if (!EntityLinking.currentSpanId || !EntityLinking.currentInstanceId) { | |
| return; | |
| } | |
| try { | |
| const response = await fetch('/api/entity_linking/update_span', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| instance_id: EntityLinking.currentInstanceId, | |
| span_id: EntityLinking.currentSpanId, | |
| kb_id: null, | |
| kb_source: null, | |
| kb_label: null | |
| }) | |
| }); | |
| if (response.ok) { | |
| // Update the span overlay element | |
| const spanElement = document.querySelector( | |
| `.span-overlay-pure[data-annotation-id="${CSS.escape(EntityLinking.currentSpanId)}"]` | |
| ); | |
| if (spanElement) { | |
| spanElement.removeAttribute('data-kb-id'); | |
| spanElement.removeAttribute('data-kb-source'); | |
| spanElement.removeAttribute('data-kb-label'); | |
| spanElement.classList.remove('has-entity-link'); | |
| // Update link icon | |
| const icon = spanElement.querySelector('.el-link-icon'); | |
| if (icon) { | |
| icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>'; | |
| icon.title = 'Link to knowledge base'; | |
| } | |
| } | |
| document.getElementById('el-current-link').style.display = 'none'; | |
| console.log('[EntityLinking] Entity link removed'); | |
| } | |
| } catch (e) { | |
| console.error('[EntityLinking] Error removing entity link:', e); | |
| } | |
| } | |
| /** | |
| * Escape HTML special characters | |
| */ | |
| function escapeHtml(text) { | |
| if (!text) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // Initialize when DOM is ready | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |
| // Also initialize when span overlays are added to the page | |
| const observer = new MutationObserver((mutations) => { | |
| mutations.forEach((mutation) => { | |
| mutation.addedNodes.forEach((node) => { | |
| if (node.nodeType === Node.ELEMENT_NODE) { | |
| // Check if the added node is a span overlay | |
| if (node.classList && node.classList.contains('span-overlay-pure')) { | |
| if (!node.querySelector('.el-link-icon')) { | |
| addLinkIconToSpan(node); | |
| } | |
| } | |
| // Also check child nodes for span overlays | |
| const overlays = node.querySelectorAll && node.querySelectorAll('.span-overlay-pure'); | |
| if (overlays) { | |
| overlays.forEach(overlay => { | |
| if (!overlay.querySelector('.el-link-icon')) { | |
| addLinkIconToSpan(overlay); | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| }); | |
| }); | |
| // Start observing when DOM is ready | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => { | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| }); | |
| } else { | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| // Expose for external use | |
| window.EntityLinking = { | |
| init: init, | |
| openModal: openModal, | |
| closeModal: closeModal, | |
| search: performSearch | |
| }; | |
| })(); | |