codebook / potato /static /entity-linking.js
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
36.5 kB
/**
* 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">&times;</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
};
})();