LLM-LOD / templates /index.html
wjbmattingly's picture
Upload 4 files
71303dd verified
raw
history blame
97.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Text Annotation Tool</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8fafc;
min-height: 100vh;
overflow-x: hidden;
}
.container {
width: 100vw;
min-height: 100vh;
background: white;
display: flex;
flex-direction: column;
}
.header {
background: linear-gradient(90deg, #4f46e5, #7c3aed);
color: white;
padding: 20px 30px;
text-align: center;
flex-shrink: 0;
}
.header h1 {
font-size: 2rem;
margin-bottom: 8px;
font-weight: 700;
}
.header p {
font-size: 1rem;
opacity: 0.9;
}
.main-layout {
display: grid;
grid-template-columns: 350px 1fr 400px;
height: calc(100vh - 120px);
flex: 1;
}
.left-sidebar {
background: #f8fafc;
border-right: 1px solid #e2e8f0;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 25px;
}
.main-content {
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.right-sidebar {
background: #f8fafc;
border-left: 1px solid #e2e8f0;
padding: 20px;
overflow-y: auto;
}
.input-section {
display: flex;
flex-direction: column;
}
.viewer-section {
flex: 1;
display: flex;
flex-direction: column;
}
.annotation-viewer {
flex: 1;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.8;
background: white;
user-select: text;
cursor: text;
overflow-y: auto;
min-height: 300px;
}
.gliner-section-main {
margin-top: 20px;
padding: 15px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
}
.annotation-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 15px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.annotation-card:hover {
border-color: #4f46e5;
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.15);
transform: translateY(-1px);
}
.annotation-card.linked {
border-left: 4px solid #10b981;
}
.annotation-text-display {
font-weight: 600;
color: #374151;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.annotation-label-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
border: 1px solid;
}
.annotation-meta {
color: #6b7280;
font-size: 12px;
margin-bottom: 10px;
}
.wikidata-info {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 10px;
margin-top: 10px;
}
.wikidata-link {
display: flex;
align-items: center;
gap: 6px;
color: #059669;
text-decoration: none;
font-size: 12px;
font-weight: 500;
}
.wikidata-link:hover {
color: #047857;
}
.wikidata-description {
color: #065f46;
font-size: 11px;
margin-top: 4px;
}
.link-controls {
display: flex;
gap: 8px;
margin-top: 10px;
}
.link-btn {
flex: 1;
padding: 6px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.link-btn:hover {
background: #f3f4f6;
}
.link-btn.primary {
background: #4f46e5;
color: white;
border-color: #4f46e5;
}
.link-btn.primary:hover {
background: #4338ca;
}
.search-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.search-modal-content {
background: white;
border-radius: 12px;
padding: 24px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.search-modal-header {
display: flex;
justify-content: between;
align-items: center;
margin-bottom: 20px;
}
.search-modal h3 {
margin: 0;
color: #374151;
}
.close-modal {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6b7280;
padding: 0;
margin-left: auto;
}
.search-input-group {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.search-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
}
.search-btn {
padding: 10px 20px;
background: #4f46e5;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
}
.search-btn:hover {
background: #4338ca;
}
.search-results {
flex: 1;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 6px;
max-height: 400px;
}
.search-result-item {
padding: 12px;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.2s ease;
}
.search-result-item:hover {
background: #f8fafc;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-label {
font-weight: 600;
color: #374151;
margin-bottom: 4px;
}
.search-result-qcode {
color: #4f46e5;
font-size: 12px;
font-weight: 500;
margin-bottom: 4px;
}
.search-result-description {
color: #6b7280;
font-size: 12px;
line-height: 1.4;
}
.qcode-input-section {
margin-bottom: 20px;
padding: 15px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.qcode-input-group {
display: flex;
gap: 10px;
align-items: center;
}
.qcode-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
font-family: monospace;
}
.qcode-btn {
padding: 8px 16px;
background: #10b981;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.qcode-btn:hover {
background: #059669;
}
.auto-link-section {
margin-top: 15px;
padding: 15px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
}
.auto-link-checkbox {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.auto-link-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.auto-link-checkbox label {
font-size: 14px;
font-weight: 500;
color: #374151;
cursor: pointer;
}
.auto-link-info {
font-size: 12px;
color: #0369a1;
line-height: 1.4;
}
.relationship-labels-section {
margin-top: 15px;
padding: 15px;
background: #fef7ff;
border: 1px solid #e879f9;
border-radius: 8px;
}
.tabs {
display: flex;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 20px;
}
.tab {
flex: 1;
padding: 12px 16px;
background: #f8fafc;
border: none;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s ease;
}
.tab:first-child {
border-radius: 8px 0 0 0;
}
.tab:last-child {
border-radius: 0 8px 0 0;
}
.tab.active {
background: white;
color: #4f46e5;
border-bottom: 2px solid #4f46e5;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.relationship-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 15px;
margin-bottom: 12px;
transition: all 0.2s ease;
}
.relationship-card:hover {
border-color: #a855f7;
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.15);
}
.relationship-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.relationship-text {
font-weight: 600;
color: #374151;
}
.relationship-controls {
display: flex;
gap: 8px;
}
.relationship-dropdown {
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
background: white;
color: #374151;
}
.reverse-btn, .delete-rel-btn {
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.reverse-btn:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.delete-rel-btn:hover {
background: #fef2f2;
border-color: #ef4444;
color: #ef4444;
}
.relationship-info {
color: #6b7280;
font-size: 12px;
margin-top: 8px;
}
.relationship-editor {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 12px;
margin-top: 10px;
display: none;
}
.relationship-editor.active {
display: block;
}
.relationship-triplet {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
gap: 8px;
align-items: center;
margin-bottom: 10px;
}
.entity-dropdown, .relation-dropdown {
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
background: white;
min-width: 120px;
max-width: 200px;
cursor: pointer;
z-index: 1000;
position: relative;
}
.relation-dropdown {
min-width: 100px;
}
.entity-dropdown:focus, .relation-dropdown:focus {
outline: 2px solid #3b82f6;
border-color: #3b82f6;
}
.entity-dropdown:hover, .relation-dropdown:hover {
border-color: #6b7280;
}
.relation-arrow {
color: #6b7280;
font-weight: bold;
text-align: center;
}
.edit-controls {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.save-btn, .cancel-btn {
padding: 4px 12px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.save-btn {
background: #10b981;
color: white;
border-color: #10b981;
}
.save-btn:hover {
background: #059669;
}
.cancel-btn:hover {
background: #f3f4f6;
}
.add-relationship-section {
margin-top: 20px;
padding: 15px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
}
.add-relationship-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-row {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
gap: 8px;
align-items: center;
}
.add-rel-btn {
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
}
.add-rel-btn:hover {
background: #2563eb;
}
.add-rel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 15px;
color: #374151;
}
.text-input {
width: 100%;
height: 200px;
border: 2px solid #e5e7eb;
border-radius: 8px;
padding: 15px;
font-size: 13px;
line-height: 1.5;
resize: vertical;
font-family: 'Courier New', monospace;
transition: border-color 0.3s ease;
}
.text-input:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.process-btn {
margin-top: 15px;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.process-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(79, 70, 229, 0.4);
}
.process-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.text-viewer {
background: #fafafa;
color: #6b7280;
font-style: italic;
}
.text-viewer.selectable {
background: white;
color: #374151;
font-style: normal;
}
.highlight {
border-radius: 3px;
padding: 1px 2px;
margin: 0 1px;
transition: all 0.2s ease;
border: 2px solid;
position: relative;
display: inline-block;
}
.highlight:hover {
opacity: 0.8;
transform: scale(1.02);
}
.highlight:hover .delete-annotation {
display: block;
}
.delete-annotation {
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background: #ef4444;
color: white;
border: none;
border-radius: 50%;
font-size: 10px;
cursor: pointer;
display: none;
z-index: 10;
line-height: 1;
font-weight: bold;
transition: all 0.2s ease;
}
.delete-annotation:hover {
background: #dc2626;
transform: scale(1.1);
}
.labels-section {
margin-bottom: 20px;
padding: 20px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.labels-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
}
.labels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
margin-bottom: 15px;
}
.label-item {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
border-radius: 6px;
border: 2px solid;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.2s ease;
position: relative;
}
.label-item:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.label-item.selected {
transform: scale(1.05);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.3);
}
.label-item.custom {
position: relative;
}
.delete-label {
position: absolute;
top: -5px;
right: -5px;
width: 16px;
height: 16px;
background: #ef4444;
color: white;
border: none;
border-radius: 50%;
font-size: 10px;
cursor: pointer;
display: none;
}
.label-item.custom:hover .delete-label {
display: block;
}
.add-label-form {
display: flex;
gap: 10px;
align-items: center;
}
.label-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
}
.color-input {
width: 40px;
height: 32px;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
}
.add-label-btn {
padding: 8px 16px;
background: #4f46e5;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
font-weight: 500;
}
.add-label-btn:hover {
background: #4338ca;
}
.gliner-section {
margin-top: 15px;
padding: 15px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
}
.gliner-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.gliner-btn {
padding: 10px 20px;
background: linear-gradient(90deg, #10b981, #059669);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.gliner-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.gliner-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.gliner-info {
font-size: 13px;
color: #065f46;
margin-top: 8px;
}
.gliner-results {
margin-top: 10px;
font-size: 14px;
font-weight: 500;
}
.selected-label-indicator {
margin-top: 10px;
padding: 10px;
background: white;
border-radius: 6px;
border: 1px solid #e2e8f0;
font-size: 14px;
font-weight: 500;
}
.instructions {
margin-top: 15px;
padding: 15px;
background: #f3f4f6;
border-radius: 8px;
color: #6b7280;
font-size: 14px;
}
.status {
margin-top: 10px;
padding: 10px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.status.success {
background: #d1fae5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.status.error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
.annotations-list {
margin-top: 20px;
}
.annotation-item {
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 12px;
margin-bottom: 10px;
font-size: 14px;
}
.annotation-text {
font-weight: 600;
color: #374151;
margin-bottom: 5px;
}
.annotation-range {
color: #6b7280;
font-size: 12px;
}
.annotation-label {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
margin-left: 8px;
border: 1px solid;
}
@media (max-width: 1200px) {
.main-layout {
grid-template-columns: 300px 1fr 350px;
}
}
@media (max-width: 1000px) {
.main-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.left-sidebar, .right-sidebar {
max-height: 300px;
}
.header h1 {
font-size: 1.8rem;
}
}
@media (max-width: 768px) {
.main-layout {
height: auto;
min-height: calc(100vh - 120px);
}
.left-sidebar, .right-sidebar {
max-height: 250px;
}
.header h1 {
font-size: 1.6rem;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Text Annotation Tool</h1>
<p>Paste your text and highlight sections to create annotations with automatic token boundary detection</p>
</div>
<div class="main-layout">
<!-- Left Sidebar: Input & Labels -->
<div class="left-sidebar">
<div class="input-section">
<h2 class="section-title">📝 Input Text</h2>
<textarea
id="textInput"
class="text-input"
placeholder="Paste your text here...&#10;&#10;Example: 'The quick brown fox jumps over the lazy dog. This is a sample text for annotation.'"
></textarea>
<button id="processBtn" class="process-btn">Process Text</button>
<div id="status" class="status" style="display: none;"></div>
</div>
<div class="labels-section">
<div class="labels-header">
<h3 class="section-title" style="margin: 0;">🏷️ Annotation Labels</h3>
</div>
<div id="labelsGrid" class="labels-grid"></div>
<div class="add-label-form">
<input type="text" id="labelInput" class="label-input" placeholder="Enter new label name..." maxlength="20">
<input type="color" id="colorInput" class="color-input" value="#fbbf24">
<button id="addLabelBtn" class="add-label-btn">Add Label</button>
</div>
<div id="selectedLabelIndicator" class="selected-label-indicator">
No label selected. Click a label above to select it for annotation.
</div>
</div>
<div class="gliner-section">
<div class="gliner-controls">
<button id="glinerBtn" class="gliner-btn" disabled>
<span>🤖</span>
Run GLiNER
</button>
<div id="glinerResults" class="gliner-results" style="display: none;"></div>
</div>
<div class="gliner-info">
Automatically extract entities using GLiNER AI model.
Process your text first, then click to find entities matching your labels.
</div>
</div>
<div class="auto-link-section">
<div class="auto-link-checkbox">
<input type="checkbox" id="autoLinkCheckbox" />
<label for="autoLinkCheckbox">Auto-link to Wikidata</label>
</div>
<div class="auto-link-info">
When enabled, automatically searches Wikidata for each entity and links to the first result.
Works with both manual annotations and GLiNER extractions.
</div>
</div>
<div class="relationship-labels-section">
<div class="labels-header">
<h3 class="section-title" style="margin: 0;">🔗 Relationship Labels</h3>
</div>
<div id="relationshipLabelsGrid" class="labels-grid"></div>
<div class="add-label-form">
<input type="text" id="relationshipLabelInput" class="label-input" placeholder="Enter relationship name..." maxlength="20">
<input type="color" id="relationshipColorInput" class="color-input" value="#e879f9">
<button id="addRelationshipLabelBtn" class="add-label-btn">Add Relationship</button>
</div>
<div class="entity-labels-info" style="margin-top: 15px; padding: 10px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; font-size: 12px;">
<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">📋 Entity Labels Found:</div>
<div id="entityLabelsDisplay" style="color: #6b7280;">Run GLiNER entities first to see available entity types</div>
<div style="margin-top: 8px; color: #6b7280; font-style: italic;">Relationships will be extracted between these entity types</div>
</div>
<button id="extractRelationshipsBtn" class="gliner-btn" disabled style="margin-top: 15px; width: 100%;">
<span>🔗</span>
Extract Relationships
</button>
<div id="relationshipResults" class="gliner-results" style="display: none;"></div>
</div>
</div>
<!-- Main Content: Annotation Viewer -->
<div class="main-content">
<div class="viewer-section">
<h2 class="section-title">🎯 Annotation Viewer</h2>
<div id="textViewer" class="annotation-viewer">
Process your text to start annotating...
</div>
<div class="instructions">
<strong>Instructions:</strong> Select a label from the left sidebar, then highlight text in the viewer above.
The selection will automatically snap to token boundaries and be colored according to the selected label.
</div>
</div>
</div>
<!-- Right Sidebar: Linked Annotations -->
<div class="right-sidebar">
<div class="tabs">
<button class="tab active" onclick="switchTab('entities')">🏷️ Entities</button>
<button class="tab" onclick="switchTab('relationships')">🔗 Relationships</button>
</div>
<div id="entitiesTab" class="tab-content active">
<div id="sidebarAnnotations"></div>
</div>
<div id="relationshipsTab" class="tab-content">
<div class="add-relationship-section">
<h4 style="margin: 0 0 10px 0; color: #374151;">➕ Add New Relationship</h4>
<div class="add-relationship-form">
<div class="form-row">
<select id="newSubjectSelect" class="entity-dropdown">
<option value="">Select Subject</option>
</select>
<div class="relation-arrow"></div>
<select id="newRelationSelect" class="relation-dropdown">
<option value="">Select Relation</option>
</select>
<div class="relation-arrow"></div>
<select id="newObjectSelect" class="entity-dropdown">
<option value="">Select Object</option>
</select>
</div>
<button id="addRelationshipBtn" class="add-rel-btn" disabled>Add Relationship</button>
</div>
</div>
<div id="sidebarRelationships"></div>
</div>
</div>
</div>
</div>
<!-- Wikidata Search Modal -->
<div id="searchModal" class="search-modal">
<div class="search-modal-content">
<div class="search-modal-header">
<h3>Link to Wikidata</h3>
<button class="close-modal" onclick="closeSearchModal()">×</button>
</div>
<div class="qcode-input-section">
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #374151;">Direct Q-code Input:</label>
<div class="qcode-input-group">
<input type="text" id="qcodeInput" class="qcode-input" placeholder="Q12345 or 12345">
<button id="qcodeBtn" class="qcode-btn">Link Q-code</button>
</div>
</div>
<div style="text-align: center; margin: 10px 0; color: #6b7280; font-size: 14px;">OR</div>
<div class="search-input-group">
<input type="text" id="wikidataSearchInput" class="search-input" placeholder="Search Wikidata entities...">
<button id="wikidataSearchBtn" class="search-btn">Search</button>
</div>
<div id="searchResults" class="search-results"></div>
</div>
</div>
<script>
let currentTokens = [];
let annotations = [];
let annotationCounter = 0;
let availableLabels = [];
let selectedLabel = null;
let originalText = '';
let currentAnnotationForLink = null;
let relationships = [];
let relationshipCounter = 0;
let availableRelationshipLabels = [];
let currentActiveTab = 'entities';
const textInput = document.getElementById('textInput');
const processBtn = document.getElementById('processBtn');
const textViewer = document.getElementById('textViewer');
const status = document.getElementById('status');
const annotationsList = document.getElementById('annotationsList');
const labelsGrid = document.getElementById('labelsGrid');
const labelInput = document.getElementById('labelInput');
const colorInput = document.getElementById('colorInput');
const addLabelBtn = document.getElementById('addLabelBtn');
const selectedLabelIndicator = document.getElementById('selectedLabelIndicator');
const glinerBtn = document.getElementById('glinerBtn');
const glinerResults = document.getElementById('glinerResults');
const sidebarAnnotations = document.getElementById('sidebarAnnotations');
const searchModal = document.getElementById('searchModal');
const wikidataSearchInput = document.getElementById('wikidataSearchInput');
const wikidataSearchBtn = document.getElementById('wikidataSearchBtn');
const searchResults = document.getElementById('searchResults');
const qcodeInput = document.getElementById('qcodeInput');
const qcodeBtn = document.getElementById('qcodeBtn');
const autoLinkCheckbox = document.getElementById('autoLinkCheckbox');
const relationshipLabelsGrid = document.getElementById('relationshipLabelsGrid');
const relationshipLabelInput = document.getElementById('relationshipLabelInput');
const relationshipColorInput = document.getElementById('relationshipColorInput');
const addRelationshipLabelBtn = document.getElementById('addRelationshipLabelBtn');
const extractRelationshipsBtn = document.getElementById('extractRelationshipsBtn');
const relationshipResults = document.getElementById('relationshipResults');
const sidebarRelationships = document.getElementById('sidebarRelationships');
const newSubjectSelect = document.getElementById('newSubjectSelect');
const newRelationSelect = document.getElementById('newRelationSelect');
const newObjectSelect = document.getElementById('newObjectSelect');
const addRelationshipBtn = document.getElementById('addRelationshipBtn');
const entityLabelsDisplay = document.getElementById('entityLabelsDisplay');
function showStatus(message, isError = false) {
status.textContent = message;
status.className = `status ${isError ? 'error' : 'success'}`;
status.style.display = 'block';
setTimeout(() => {
status.style.display = 'none';
}, 3000);
}
function updateAnnotationsList() {
updateSidebarAnnotations();
// Update relationship form when annotations change
updateRelationshipForm();
// Update entity labels display
updateEntityLabelsDisplay();
}
function updateSidebarAnnotations() {
if (annotations.length === 0) {
sidebarAnnotations.innerHTML = '<p style="color: #6b7280; font-style: italic; text-align: center; margin-top: 40px;">No annotations yet.<br><br>Select a label and highlight text in the viewer to create annotations.</p>';
return;
}
sidebarAnnotations.innerHTML = annotations.map(annotation => {
const label = availableLabels.find(l => l.name === annotation.label);
const labelStyle = label ? `background-color: ${label.color}; border-color: ${label.border}; color: ${label.border};` : '';
const isLinked = annotation.wikidata && annotation.wikidata.id;
return `
<div class="annotation-card ${isLinked ? 'linked' : ''}" data-annotation-id="${annotation.id}">
<div class="annotation-text-display">
"${annotation.text}"
<span class="annotation-label-badge" style="${labelStyle}">${annotation.label}</span>
</div>
<div class="annotation-meta">
Characters ${annotation.start}-${annotation.end} | Token boundary aligned
</div>
${isLinked ? `
<div class="wikidata-info">
<a href="${annotation.wikidata.url}" target="_blank" class="wikidata-link">
🔗 ${annotation.wikidata.id}: ${annotation.wikidata.label}
</a>
${annotation.wikidata.description ? `<div class="wikidata-description">${annotation.wikidata.description}</div>` : ''}
</div>
` : ''}
<div class="link-controls">
<button class="link-btn primary" onclick="openSearchModal(${annotation.id})">
${isLinked ? 'Change Link' : 'Link to Wikidata'}
</button>
${isLinked ? `<button class="link-btn" onclick="unlinkAnnotation(${annotation.id})">Unlink</button>` : ''}
</div>
</div>
`;
}).join('');
}
function renderLabels() {
labelsGrid.innerHTML = availableLabels.map(label => `
<div class="label-item ${selectedLabel?.name === label.name ? 'selected' : ''} ${!label.isDefault ? 'custom' : ''}"
data-label="${label.name}"
style="background-color: ${label.color}; border-color: ${label.border}; color: ${label.border};">
${label.name}
${!label.isDefault ? `<button class="delete-label" onclick="deleteLabel('${label.name}')" title="Delete label">×</button>` : ''}
</div>
`).join('');
// Update selected label indicator
if (selectedLabel) {
selectedLabelIndicator.innerHTML = `
<span style="color: ${selectedLabel.border};"></span>
Selected: <strong>${selectedLabel.name}</strong> - Click text to annotate with this label
`;
selectedLabelIndicator.style.backgroundColor = selectedLabel.color;
selectedLabelIndicator.style.borderColor = selectedLabel.border;
} else {
selectedLabelIndicator.innerHTML = 'No label selected. Click a label above to select it for annotation.';
selectedLabelIndicator.style.backgroundColor = 'white';
selectedLabelIndicator.style.borderColor = '#e2e8f0';
}
// Update GLiNER button state
updateGlinerButton();
// Update relationship extraction button state
updateExtractRelationshipsButton();
}
function selectLabel(labelName) {
selectedLabel = availableLabels.find(l => l.name === labelName);
renderLabels();
}
function addLabel() {
const name = labelInput.value.trim().toUpperCase();
const color = colorInput.value;
if (!name) {
showStatus('Please enter a label name.', true);
return;
}
if (availableLabels.some(l => l.name === name)) {
showStatus('Label already exists.', true);
return;
}
// Generate border color (darker version of the selected color)
const borderColor = adjustBrightness(color, -40);
const newLabel = {
name: name,
color: color + '40', // Add transparency
border: borderColor,
isDefault: false
};
availableLabels.push(newLabel);
renderLabels();
labelInput.value = '';
colorInput.value = '#fbbf24';
showStatus(`Label "${name}" added successfully!`);
}
function deleteLabel(labelName) {
if (confirm(`Delete label "${labelName}"?`)) {
availableLabels = availableLabels.filter(l => l.name !== labelName);
if (selectedLabel?.name === labelName) {
selectedLabel = null;
}
// Remove annotations with this label
annotations = annotations.filter(a => a.label !== labelName);
renderLabels();
updateAnnotationsList();
showStatus(`Label "${labelName}" deleted.`);
}
}
function adjustBrightness(hex, percent) {
// Remove # if present
hex = hex.replace('#', '');
// Convert to RGB
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
// Adjust brightness
const newR = Math.max(0, Math.min(255, r + (r * percent / 100)));
const newG = Math.max(0, Math.min(255, g + (g * percent / 100)));
const newB = Math.max(0, Math.min(255, b + (b * percent / 100)));
// Convert back to hex
return '#' +
Math.round(newR).toString(16).padStart(2, '0') +
Math.round(newG).toString(16).padStart(2, '0') +
Math.round(newB).toString(16).padStart(2, '0');
}
async function loadDefaultLabels() {
try {
const response = await fetch('/get_default_labels');
const data = await response.json();
availableLabels = data.labels.map(label => ({
...label,
isDefault: true
}));
renderLabels();
} catch (error) {
console.error('Error loading default labels:', error);
showStatus('Error loading labels.', true);
}
}
function updateGlinerButton() {
const hasText = originalText && originalText !== 'Process your text to start annotating...';
const hasLabels = availableLabels.length > 0;
glinerBtn.disabled = !hasText || !hasLabels;
if (!hasText) {
glinerBtn.title = 'Process text first';
} else if (!hasLabels) {
glinerBtn.title = 'Add some labels first';
} else {
glinerBtn.title = 'Run automatic entity extraction';
}
}
async function runGliner() {
const text = originalText; // Use original text, not current textContent
const labelNames = availableLabels.map(label => label.name);
if (!text || text === 'Process your text to start annotating...') {
showStatus('Please process text first.', true);
return;
}
if (labelNames.length === 0) {
showStatus('Please add some labels first.', true);
return;
}
glinerBtn.disabled = true;
glinerBtn.innerHTML = '<span></span> Running GLiNER...';
glinerResults.style.display = 'none';
try {
const response = await fetch('/run_gliner', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text,
labels: labelNames
})
});
if (!response.ok) {
throw new Error('Failed to run GLiNER');
}
const data = await response.json();
// Add all found entities as annotations
let addedCount = 0;
data.entities.forEach(entity => {
// Check if this annotation already exists
const exists = annotations.some(ann =>
ann.start === entity.start &&
ann.end === entity.end &&
ann.label === entity.label
);
if (!exists) {
const annotation = {
id: ++annotationCounter,
text: entity.text,
start: entity.start,
end: entity.end,
label: entity.label,
confidence: entity.confidence
};
annotations.push(annotation);
addedCount++;
}
});
// Auto-link new annotations if enabled
if (autoLinkCheckbox.checked && addedCount > 0) {
await autoLinkAllAnnotations();
}
// Update UI
updateAnnotationsList();
renderHighlights();
// Show results
glinerResults.innerHTML = `Found ${data.total_found} entities, added ${addedCount} new annotations`;
glinerResults.style.display = 'block';
glinerResults.style.color = addedCount > 0 ? '#065f46' : '#6b7280';
showStatus(`GLiNER completed! Found ${data.total_found} entities, added ${addedCount} new annotations.`);
} catch (error) {
showStatus('Error running GLiNER. Please try again.', true);
console.error('Error:', error);
glinerResults.innerHTML = 'Error occurred during processing';
glinerResults.style.display = 'block';
glinerResults.style.color = '#dc2626';
} finally {
glinerBtn.disabled = false;
glinerBtn.innerHTML = '<span>🤖</span> Run GLiNER';
updateGlinerButton();
}
}
async function processText() {
const text = textInput.value.trim();
if (!text) {
showStatus('Please enter some text first.', true);
return;
}
processBtn.disabled = true;
processBtn.textContent = 'Processing...';
try {
const response = await fetch('/tokenize', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: text })
});
if (!response.ok) {
throw new Error('Failed to process text');
}
const data = await response.json();
currentTokens = data.tokens;
// Store the original text and display it in the viewer
originalText = text;
textViewer.textContent = text;
textViewer.classList.add('selectable');
// Clear previous annotations
annotations = [];
updateAnnotationsList();
// Enable GLiNER button if there are labels
updateGlinerButton();
// Enable relationship extraction button if there are relationship labels
updateExtractRelationshipsButton();
showStatus(`Text processed successfully! Found ${currentTokens.length} tokens.`);
} catch (error) {
showStatus('Error processing text. Please try again.', true);
console.error('Error:', error);
} finally {
processBtn.disabled = false;
processBtn.textContent = 'Process Text';
}
}
async function handleTextSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0 || selection.isCollapsed) {
return;
}
if (!selectedLabel) {
showStatus('Please select a label first before annotating.', true);
selection.removeAllRanges();
return;
}
// Use the new method to get accurate offsets
const selectionData = getSelectionOffsets();
if (!selectionData || selectionData.start === -1 || selectionData.end === -1) {
showStatus('Could not determine selection boundaries.', true);
selection.removeAllRanges();
return;
}
const startOffset = selectionData.start;
const endOffset = selectionData.end;
if (startOffset === endOffset) {
return;
}
try {
const response = await fetch('/find_token_boundaries', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: originalText, // Use original text, not current textContent
start: Math.min(startOffset, endOffset),
end: Math.max(startOffset, endOffset),
label: selectedLabel.name
})
});
if (!response.ok) {
throw new Error('Failed to find token boundaries');
}
const data = await response.json();
// Check if there's already an annotation at this exact position
const existingAnnotation = annotations.find(ann =>
ann.start === data.start && ann.end === data.end
);
if (existingAnnotation) {
// If annotation exists with same label, do nothing
if (existingAnnotation.label === data.label) {
showStatus(`Text already annotated as ${data.label}`, true);
selection.removeAllRanges();
return;
}
// If annotation exists with different label, update it
existingAnnotation.label = data.label;
showStatus(`Updated annotation to ${data.label}`);
} else {
// Create new annotation
const annotation = {
id: ++annotationCounter,
text: data.selected_text,
start: data.start,
end: data.end,
label: data.label
};
annotations.push(annotation);
// Auto-link if enabled
if (autoLinkCheckbox.checked) {
await autoLinkAnnotation(annotation);
}
showStatus(`Annotated "${data.selected_text}" as ${data.label}`);
}
updateAnnotationsList();
// Re-render all highlights
renderHighlights();
// Clear selection
selection.removeAllRanges();
} catch (error) {
showStatus('Error creating annotation. Please try again.', true);
console.error('Error:', error);
}
}
function getTextOffset(node, offset) {
// Build a map of the actual text content and its positions
let textOffset = 0;
let found = false;
// Walk through all nodes in the text viewer
const walker = document.createTreeWalker(
textViewer,
NodeFilter.SHOW_TEXT,
{
acceptNode: function(textNode) {
// Skip text nodes that are inside delete buttons
if (textNode.parentNode && textNode.parentNode.classList &&
textNode.parentNode.classList.contains('delete-annotation')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
},
false
);
let currentNode;
while (currentNode = walker.nextNode()) {
if (currentNode === node) {
return textOffset + offset;
}
textOffset += currentNode.textContent.length;
}
// If we didn't find the node, try a fallback method
return offset;
}
function getSelectionOffsets() {
const selection = window.getSelection();
if (selection.rangeCount === 0) {
return null;
}
const range = selection.getRangeAt(0);
// Get the actual text content without HTML elements (except delete buttons)
let fullText = '';
let startOffset = -1;
let endOffset = -1;
let currentOffset = 0;
const walker = document.createTreeWalker(
textViewer,
NodeFilter.SHOW_TEXT,
{
acceptNode: function(node) {
// Skip text nodes that are inside delete buttons
if (node.parentNode && node.parentNode.classList &&
node.parentNode.classList.contains('delete-annotation')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
},
false
);
let textNode;
while (textNode = walker.nextNode()) {
const nodeText = textNode.textContent;
const nodeLength = nodeText.length;
// Check if this node contains the start of our selection
if (textNode === range.startContainer) {
startOffset = currentOffset + range.startOffset;
}
// Check if this node contains the end of our selection
if (textNode === range.endContainer) {
endOffset = currentOffset + range.endOffset;
}
fullText += nodeText;
currentOffset += nodeLength;
}
return {
start: startOffset,
end: endOffset,
text: fullText
};
}
function getPlainTextLength() {
// Calculate the length of the original text without any HTML elements
return originalText.length;
}
function renderHighlights() {
// Always use the original text, never the current textContent
const text = originalText;
if (!text) {
return;
}
// Sort annotations by start position
const sortedAnnotations = [...annotations].sort((a, b) => a.start - b.start);
textViewer.innerHTML = '';
let lastEnd = 0;
sortedAnnotations.forEach(annotation => {
// Add text before annotation
if (annotation.start > lastEnd) {
textViewer.appendChild(document.createTextNode(text.substring(lastEnd, annotation.start)));
}
// Add highlighted text with delete button
const label = availableLabels.find(l => l.name === annotation.label);
const highlight = document.createElement('span');
highlight.className = 'highlight';
highlight.textContent = text.substring(annotation.start, annotation.end);
highlight.dataset.annotationId = annotation.id;
if (label) {
highlight.style.backgroundColor = label.color;
highlight.style.borderColor = label.border;
}
highlight.title = `${annotation.label}: ${annotation.text}`;
// Add delete button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-annotation';
deleteBtn.innerHTML = '×';
deleteBtn.title = 'Delete annotation';
deleteBtn.onclick = (e) => {
e.stopPropagation();
deleteAnnotation(annotation.id);
};
highlight.appendChild(deleteBtn);
textViewer.appendChild(highlight);
lastEnd = Math.max(lastEnd, annotation.end);
});
// Add remaining text
if (lastEnd < text.length) {
textViewer.appendChild(document.createTextNode(text.substring(lastEnd)));
}
}
function deleteAnnotation(annotationId) {
// Find and remove the annotation
const annotationIndex = annotations.findIndex(ann => ann.id === annotationId);
if (annotationIndex !== -1) {
const deletedAnnotation = annotations[annotationIndex];
annotations.splice(annotationIndex, 1);
// Update UI
updateAnnotationsList();
renderHighlights();
showStatus(`Deleted annotation: "${deletedAnnotation.text}" (${deletedAnnotation.label})`);
}
}
// Wikidata functions
function openSearchModal(annotationId) {
currentAnnotationForLink = annotations.find(ann => ann.id === annotationId);
if (!currentAnnotationForLink) return;
// Pre-fill search with annotation text
wikidataSearchInput.value = currentAnnotationForLink.text;
qcodeInput.value = '';
searchResults.innerHTML = '';
searchModal.style.display = 'flex';
}
function closeSearchModal() {
searchModal.style.display = 'none';
currentAnnotationForLink = null;
}
async function searchWikidata() {
const query = wikidataSearchInput.value.trim();
if (!query) {
showStatus('Please enter a search term.', true);
return;
}
wikidataSearchBtn.disabled = true;
wikidataSearchBtn.textContent = 'Searching...';
searchResults.innerHTML = '<div style="padding: 20px; text-align: center; color: #6b7280;">Searching Wikidata...</div>';
try {
const response = await fetch('/search_wikidata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: query,
limit: 10
})
});
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
if (data.results.length === 0) {
searchResults.innerHTML = '<div style="padding: 20px; text-align: center; color: #6b7280;">No results found. Try a different search term.</div>';
} else {
searchResults.innerHTML = data.results.map(result => `
<div class="search-result-item" onclick="selectWikidataEntity('${result.id}', '${result.label.replace(/'/g, "\\'")}', '${(result.description || '').replace(/'/g, "\\'")}', '${result.url}')">
<div class="search-result-label">${result.label}</div>
<div class="search-result-qcode">${result.id}</div>
${result.description ? `<div class="search-result-description">${result.description}</div>` : ''}
</div>
`).join('');
}
} catch (error) {
console.error('Error searching Wikidata:', error);
searchResults.innerHTML = '<div style="padding: 20px; text-align: center; color: #ef4444;">Search failed. Please try again.</div>';
} finally {
wikidataSearchBtn.disabled = false;
wikidataSearchBtn.textContent = 'Search';
}
}
async function linkQcode() {
const qcode = qcodeInput.value.trim();
if (!qcode) {
showStatus('Please enter a Q-code.', true);
return;
}
qcodeBtn.disabled = true;
qcodeBtn.textContent = 'Linking...';
try {
const response = await fetch('/get_wikidata_entity', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
qcode: qcode
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to get entity');
}
const data = await response.json();
selectWikidataEntity(data.entity.id, data.entity.label, data.entity.description, data.entity.url);
} catch (error) {
console.error('Error linking Q-code:', error);
showStatus(`Error: ${error.message}`, true);
} finally {
qcodeBtn.disabled = false;
qcodeBtn.textContent = 'Link Q-code';
}
}
function selectWikidataEntity(id, label, description, url) {
if (!currentAnnotationForLink) return;
// Link the annotation to Wikidata
currentAnnotationForLink.wikidata = {
id: id,
label: label,
description: description,
url: url
};
updateSidebarAnnotations();
closeSearchModal();
showStatus(`Linked "${currentAnnotationForLink.text}" to ${id}: ${label}`);
}
function unlinkAnnotation(annotationId) {
const annotation = annotations.find(ann => ann.id === annotationId);
if (!annotation) return;
const entityLabel = annotation.wikidata ? annotation.wikidata.label : 'entity';
delete annotation.wikidata;
updateSidebarAnnotations();
showStatus(`Unlinked "${annotation.text}" from ${entityLabel}`);
}
async function autoLinkAnnotation(annotation) {
if (!autoLinkCheckbox.checked || annotation.wikidata) {
return; // Skip if auto-link is disabled or already linked
}
try {
const response = await fetch('/search_wikidata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: annotation.text,
limit: 1 // Only get the first result
})
});
if (!response.ok) {
console.error('Auto-link search failed for:', annotation.text);
return;
}
const data = await response.json();
if (data.results.length > 0) {
const result = data.results[0];
annotation.wikidata = {
id: result.id,
label: result.label,
description: result.description,
url: result.url
};
console.log(`Auto-linked "${annotation.text}" to ${result.id}: ${result.label}`);
}
} catch (error) {
console.error('Error auto-linking annotation:', annotation.text, error);
}
}
async function autoLinkAllAnnotations() {
if (!autoLinkCheckbox.checked) {
return;
}
let linkedCount = 0;
const unlinkedAnnotations = annotations.filter(ann => !ann.wikidata);
for (const annotation of unlinkedAnnotations) {
await autoLinkAnnotation(annotation);
if (annotation.wikidata) {
linkedCount++;
}
}
if (linkedCount > 0) {
updateSidebarAnnotations();
showStatus(`Auto-linked ${linkedCount} annotation${linkedCount > 1 ? 's' : ''} to Wikidata`);
}
}
// Event listeners
processBtn.addEventListener('click', processText);
textViewer.addEventListener('mouseup', handleTextSelection);
textViewer.addEventListener('touchend', handleTextSelection);
// Label management event listeners
labelsGrid.addEventListener('click', (e) => {
const labelItem = e.target.closest('.label-item');
if (labelItem && !e.target.classList.contains('delete-label')) {
const labelName = labelItem.dataset.label;
selectLabel(labelName);
}
});
addLabelBtn.addEventListener('click', addLabel);
labelInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
addLabel();
}
});
glinerBtn.addEventListener('click', runGliner);
// Wikidata event listeners
wikidataSearchBtn.addEventListener('click', searchWikidata);
qcodeBtn.addEventListener('click', linkQcode);
wikidataSearchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
searchWikidata();
}
});
qcodeInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
linkQcode();
}
});
// Close modal when clicking outside
searchModal.addEventListener('click', (e) => {
if (e.target === searchModal) {
closeSearchModal();
}
});
// Tab switching
function switchTab(tabName) {
currentActiveTab = tabName;
// Update tab buttons
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
if (tabName === 'entities') {
document.querySelector('.tab[onclick*="entities"]').classList.add('active');
document.getElementById('entitiesTab').classList.add('active');
} else {
document.querySelector('.tab[onclick*="relationships"]').classList.add('active');
document.getElementById('relationshipsTab').classList.add('active');
// Ensure relationship labels are loaded when switching to relationships tab
if (availableRelationshipLabels.length === 0) {
loadDefaultRelationshipLabels();
}
// Update the form regardless
updateRelationshipForm();
}
}
// Relationship functions
async function loadDefaultRelationshipLabels() {
try {
const response = await fetch('/get_default_relationship_labels');
const data = await response.json();
availableRelationshipLabels = data.relationship_labels.map(label => ({
...label,
isDefault: true
}));
renderRelationshipLabels();
updateExtractRelationshipsButton();
// Update the relationship form dropdowns
updateRelationshipForm();
} catch (error) {
console.error('Error loading default relationship labels:', error);
showStatus('Error loading relationship labels.', true);
}
}
function renderRelationshipLabels() {
relationshipLabelsGrid.innerHTML = availableRelationshipLabels.map(label => `
<div class="label-item ${!label.isDefault ? 'custom' : ''}"
data-label="${label.name}"
style="background-color: ${label.color}; border-color: ${label.border}; color: ${label.border};">
${label.name}
${!label.isDefault ? `<button class="delete-label" onclick="deleteRelationshipLabel('${label.name}')" title="Delete label">×</button>` : ''}
</div>
`).join('');
}
function addRelationshipLabel() {
const name = relationshipLabelInput.value.trim().toLowerCase();
const color = relationshipColorInput.value;
if (!name) {
showStatus('Please enter a relationship name.', true);
return;
}
if (availableRelationshipLabels.some(l => l.name === name)) {
showStatus('Relationship label already exists.', true);
return;
}
const borderColor = adjustBrightness(color, -40);
const newLabel = {
name: name,
color: color + '40',
border: borderColor,
isDefault: false
};
availableRelationshipLabels.push(newLabel);
renderRelationshipLabels();
updateExtractRelationshipsButton();
relationshipLabelInput.value = '';
relationshipColorInput.value = '#e879f9';
showStatus(`Relationship "${name}" added successfully!`);
}
function deleteRelationshipLabel(labelName) {
if (confirm(`Delete relationship label "${labelName}"?`)) {
availableRelationshipLabels = availableRelationshipLabels.filter(l => l.name !== labelName);
relationships = relationships.filter(r => r.relation_type !== labelName);
renderRelationshipLabels();
updateSidebarRelationships();
updateExtractRelationshipsButton();
showStatus(`Relationship label "${labelName}" deleted.`);
}
}
function updateExtractRelationshipsButton() {
const hasText = originalText && originalText !== 'Process your text to start annotating...';
const hasRelationshipLabels = availableRelationshipLabels.length > 0;
const hasEntities = annotations.length > 0;
extractRelationshipsBtn.disabled = !hasText || !hasRelationshipLabels;
if (!hasText) {
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Process Text First';
extractRelationshipsBtn.title = 'Process text first';
} else if (!hasEntities) {
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Run GLiNER Entities First';
extractRelationshipsBtn.title = 'Extract entities first to identify relationship participants';
} else if (!hasRelationshipLabels) {
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Add Relationship Labels';
extractRelationshipsBtn.title = 'Add some relationship labels first';
} else {
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Extract Relationships';
extractRelationshipsBtn.title = 'Extract relationships using GLiNER';
}
}
function getUniqueEntityLabels() {
// Get unique entity labels from extracted annotations
const entityLabels = [...new Set(annotations.map(ann => ann.label))];
console.log('Unique entity labels found:', entityLabels);
return entityLabels;
}
function updateEntityLabelsDisplay() {
const entityLabels = getUniqueEntityLabels();
if (entityLabels.length === 0) {
entityLabelsDisplay.innerHTML = 'Run GLiNER entities first to see available entity types';
entityLabelsDisplay.style.color = '#6b7280';
} else {
entityLabelsDisplay.innerHTML = entityLabels.map(label =>
`<span style="background: #e5e7eb; color: #374151; padding: 2px 6px; border-radius: 3px; margin-right: 4px; font-size: 11px;">${label}</span>`
).join('');
entityLabelsDisplay.style.color = '#374151';
}
}
async function extractRelationships() {
const text = originalText;
const relationshipLabelNames = availableRelationshipLabels.map(label => label.name);
if (!text || text === 'Process your text to start annotating...') {
showStatus('Please process text first.', true);
return;
}
if (relationshipLabelNames.length === 0) {
showStatus('Please add some relationship labels first.', true);
return;
}
// Check if we have entities, if not, run GLiNER first
if (annotations.length === 0) {
showStatus('No entities found. Running GLiNER entities first...', false);
extractRelationshipsBtn.disabled = true;
extractRelationshipsBtn.innerHTML = '<span></span> Extracting Entities First...';
try {
await runGliner();
if (annotations.length === 0) {
showStatus('No entities found in text. Cannot extract relationships.', true);
extractRelationshipsBtn.disabled = false;
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Extract Relationships';
return;
}
} catch (error) {
showStatus('Failed to extract entities. Please try again.', true);
extractRelationshipsBtn.disabled = false;
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Extract Relationships';
return;
}
}
extractRelationshipsBtn.disabled = true;
extractRelationshipsBtn.innerHTML = '<span></span> Extracting Relationships...';
relationshipResults.style.display = 'none';
try {
const response = await fetch('/run_gliner_relationships', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text,
relationship_labels: relationshipLabelNames,
entity_labels: getUniqueEntityLabels()
})
});
if (!response.ok) {
throw new Error('Failed to extract relationships');
}
const data = await response.json();
let addedCount = 0;
data.relationships.forEach(rel => {
const exists = relationships.some(r =>
r.subject === rel.subject &&
r.object === rel.object &&
r.relation_type === rel.relation_type
);
if (!exists) {
const relationship = {
id: ++relationshipCounter,
subject: rel.subject,
subject_start: rel.subject_start,
subject_end: rel.subject_end,
relation_type: rel.relation_type,
relation_text: rel.relation_text,
relation_start: rel.relation_start,
relation_end: rel.relation_end,
object: rel.object,
object_start: rel.object_start,
object_end: rel.object_end,
confidence: rel.confidence,
full_text: rel.full_text
};
relationships.push(relationship);
addedCount++;
}
});
updateSidebarRelationships();
relationshipResults.innerHTML = `Found ${data.total_found} relationships, added ${addedCount} new ones`;
relationshipResults.style.display = 'block';
relationshipResults.style.color = addedCount > 0 ? '#065f46' : '#6b7280';
showStatus(`Relationship extraction completed! Found ${data.total_found} relationships, added ${addedCount} new ones.`);
} catch (error) {
showStatus('Error extracting relationships. Please try again.', true);
console.error('Error:', error);
relationshipResults.innerHTML = 'Error occurred during processing';
relationshipResults.style.display = 'block';
relationshipResults.style.color = '#dc2626';
} finally {
extractRelationshipsBtn.disabled = false;
extractRelationshipsBtn.innerHTML = '<span>🔗</span> Extract Relationships';
updateExtractRelationshipsButton();
}
}
function updateSidebarRelationships() {
// Update dropdowns
updateEntityDropdowns();
updateRelationshipForm();
if (relationships.length === 0) {
sidebarRelationships.innerHTML = '<p style="color: #6b7280; font-style: italic; text-align: center; margin-top: 40px;">No relationships yet.<br><br>Add relationship labels and extract relationships from your text, or create them manually above.</p>';
return;
}
sidebarRelationships.innerHTML = relationships.map(relationship => {
const label = availableRelationshipLabels.find(l => l.name === relationship.relation_type);
const labelStyle = label ? `background-color: ${label.color}; border-color: ${label.border}; color: ${label.border};` : '';
const objectText = (relationship.object && relationship.object.trim()) || relationship.object_text || '[unknown object]';
const displayText = `${relationship.subject} → ${relationship.relation_type} → ${objectText}`;
return `
<div class="relationship-card" data-relationship-id="${relationship.id}">
<div class="relationship-header">
<div class="relationship-text">${displayText}</div>
<div class="relationship-controls">
<button class="reverse-btn" onclick="editRelationship(${relationship.id})" title="Edit relationship">✏️</button>
<button class="reverse-btn" onclick="reverseRelationship(${relationship.id})" title="Reverse direction"></button>
<button class="delete-rel-btn" onclick="deleteRelationship(${relationship.id})" title="Delete relationship">×</button>
</div>
</div>
<div class="relationship-info">
<span class="annotation-label-badge" style="${labelStyle}">${relationship.relation_type}</span>
<strong>Subject:</strong> "${relationship.subject}" (${relationship.subject_start}-${relationship.subject_end})
${(relationship.object && relationship.object.trim()) ? `<br><strong>Object:</strong> "${relationship.object}" (${relationship.object_start}-${relationship.object_end})` : '<br><strong>Object:</strong> Not detected'}
</div>
<div id="editor-${relationship.id}" class="relationship-editor">
<div class="relationship-triplet">
<select id="edit-subject-${relationship.id}" class="entity-dropdown">
${getEntityOptions(relationship.subject)}
</select>
<div class="relation-arrow"></div>
<select id="edit-relation-${relationship.id}" class="relation-dropdown">
${getRelationOptions(relationship.relation_type)}
</select>
<div class="relation-arrow"></div>
<select id="edit-object-${relationship.id}" class="entity-dropdown">
${getEntityOptions(relationship.object)}
</select>
</div>
<div class="edit-controls">
<button class="save-btn" onclick="saveRelationshipEdit(${relationship.id})">Save</button>
<button class="cancel-btn" onclick="cancelRelationshipEdit(${relationship.id})">Cancel</button>
</div>
</div>
</div>
`;
}).join('');
}
function getEntityOptions(selectedEntity = '') {
const entities = annotations.map(ann => ann.text);
const uniqueEntities = [...new Set(entities)];
return uniqueEntities.map(entity =>
`<option value="${entity}" ${entity === selectedEntity ? 'selected' : ''}>${entity}</option>`
).join('');
}
function getRelationOptions(selectedRelation = '') {
return availableRelationshipLabels.map(rel =>
`<option value="${rel.name}" ${rel.name === selectedRelation ? 'selected' : ''}>${rel.name}</option>`
).join('');
}
function updateEntityDropdowns() {
const entities = annotations.map(ann => ann.text);
const uniqueEntities = [...new Set(entities)];
// Save current selected values
const currentSubject = newSubjectSelect.value;
const currentObject = newObjectSelect.value;
// Update new relationship dropdowns
const subjectOptions = '<option value="">Select Subject</option>' +
uniqueEntities.map(entity => `<option value="${entity}">${entity}</option>`).join('');
const objectOptions = '<option value="">Select Object</option>' +
uniqueEntities.map(entity => `<option value="${entity}">${entity}</option>`).join('');
newSubjectSelect.innerHTML = subjectOptions;
newObjectSelect.innerHTML = objectOptions;
// Restore selected values if they still exist
if (currentSubject && uniqueEntities.includes(currentSubject)) {
newSubjectSelect.value = currentSubject;
}
if (currentObject && uniqueEntities.includes(currentObject)) {
newObjectSelect.value = currentObject;
}
}
function updateRelationshipForm() {
// Save current selected values before updating
const currentSubject = newSubjectSelect.value;
const currentRelation = newRelationSelect.value;
const currentObject = newObjectSelect.value;
// Update relationship dropdown
const relationOptions = '<option value="">Select Relation</option>' +
availableRelationshipLabels.map(rel => `<option value="${rel.name}">${rel.name}</option>`).join('');
newRelationSelect.innerHTML = relationOptions;
// Restore the selected value if it still exists in the options
if (currentRelation && availableRelationshipLabels.some(rel => rel.name === currentRelation)) {
newRelationSelect.value = currentRelation;
}
// Ensure dropdown is not disabled
newRelationSelect.disabled = false;
// Enable/disable add button
const hasSubject = newSubjectSelect.value !== '';
const hasRelation = newRelationSelect.value !== '';
const hasObject = newObjectSelect.value !== '';
addRelationshipBtn.disabled = !(hasSubject && hasRelation && hasObject);
// Update button text to show status
if (availableRelationshipLabels.length === 0) {
addRelationshipBtn.textContent = 'Add Relationship Labels First';
addRelationshipBtn.disabled = true;
} else if (annotations.length === 0) {
addRelationshipBtn.textContent = 'Add Entities First';
addRelationshipBtn.disabled = true;
} else if (hasSubject && hasRelation && hasObject) {
addRelationshipBtn.textContent = 'Add Relationship';
addRelationshipBtn.disabled = false;
} else {
addRelationshipBtn.textContent = 'Select All Fields';
addRelationshipBtn.disabled = true;
}
}
function changeRelationshipType(relationshipId, newType) {
const relationship = relationships.find(r => r.id === relationshipId);
if (relationship) {
relationship.relation_type = newType;
updateSidebarRelationships();
showStatus(`Updated relationship to "${newType}"`);
}
}
function reverseRelationship(relationshipId) {
const relationship = relationships.find(r => r.id === relationshipId);
if (relationship && relationship.object) {
// Swap subject and object
const tempSubject = relationship.subject;
const tempSubjectStart = relationship.subject_start;
const tempSubjectEnd = relationship.subject_end;
relationship.subject = relationship.object;
relationship.subject_start = relationship.object_start;
relationship.subject_end = relationship.object_end;
relationship.object = tempSubject;
relationship.object_start = tempSubjectStart;
relationship.object_end = tempSubjectEnd;
// Update the full text
relationship.full_text = `${relationship.subject} ${relationship.relation_type} ${relationship.object}`;
updateSidebarRelationships();
showStatus(`Reversed relationship: ${relationship.full_text}`);
} else {
showStatus('Cannot reverse relationship without both subject and object', true);
}
}
function editRelationship(relationshipId) {
// Hide all other editors
document.querySelectorAll('.relationship-editor').forEach(editor => {
editor.classList.remove('active');
});
// Show this editor
const editor = document.getElementById(`editor-${relationshipId}`);
if (editor) {
editor.classList.add('active');
}
}
function saveRelationshipEdit(relationshipId) {
const relationship = relationships.find(r => r.id === relationshipId);
if (!relationship) return;
const newSubject = document.getElementById(`edit-subject-${relationshipId}`).value;
const newRelation = document.getElementById(`edit-relation-${relationshipId}`).value;
const newObject = document.getElementById(`edit-object-${relationshipId}`).value;
if (!newSubject || !newRelation || !newObject) {
showStatus('Please select all fields', true);
return;
}
// Find the annotation data for the entities
const subjectAnnotation = annotations.find(ann => ann.text === newSubject);
const objectAnnotation = annotations.find(ann => ann.text === newObject);
// Update relationship
relationship.subject = newSubject;
relationship.relation_type = newRelation;
relationship.object = newObject;
relationship.full_text = `${newSubject} ${newRelation} ${newObject}`;
if (subjectAnnotation) {
relationship.subject_start = subjectAnnotation.start;
relationship.subject_end = subjectAnnotation.end;
}
if (objectAnnotation) {
relationship.object_start = objectAnnotation.start;
relationship.object_end = objectAnnotation.end;
}
// Hide editor and update display
document.getElementById(`editor-${relationshipId}`).classList.remove('active');
updateSidebarRelationships();
showStatus(`Updated relationship: ${relationship.full_text}`);
}
function cancelRelationshipEdit(relationshipId) {
document.getElementById(`editor-${relationshipId}`).classList.remove('active');
}
function addNewRelationship() {
const subject = newSubjectSelect.value;
const relation = newRelationSelect.value;
const object = newObjectSelect.value;
if (!subject || !relation || !object) {
showStatus('Please select all fields', true);
return;
}
// Check if relationship already exists
const exists = relationships.some(r =>
r.subject === subject &&
r.object === object &&
r.relation_type === relation
);
if (exists) {
showStatus('This relationship already exists', true);
return;
}
// Find the annotation data for the entities
const subjectAnnotation = annotations.find(ann => ann.text === subject);
const objectAnnotation = annotations.find(ann => ann.text === object);
// Create new relationship
const newRelationship = {
id: ++relationshipCounter,
subject: subject,
subject_start: subjectAnnotation ? subjectAnnotation.start : -1,
subject_end: subjectAnnotation ? subjectAnnotation.end : -1,
relation_type: relation,
relation_text: relation,
relation_start: -1,
relation_end: -1,
object: object,
object_start: objectAnnotation ? objectAnnotation.start : -1,
object_end: objectAnnotation ? objectAnnotation.end : -1,
confidence: 1.0,
full_text: `${subject} ${relation} ${object}`
};
relationships.push(newRelationship);
// Reset form
newSubjectSelect.value = '';
newRelationSelect.value = '';
newObjectSelect.value = '';
updateSidebarRelationships();
showStatus(`Added relationship: ${newRelationship.full_text}`);
}
function deleteRelationship(relationshipId) {
const relationshipIndex = relationships.findIndex(r => r.id === relationshipId);
if (relationshipIndex !== -1) {
const deletedRelationship = relationships[relationshipIndex];
relationships.splice(relationshipIndex, 1);
updateSidebarRelationships();
showStatus(`Deleted relationship: ${deletedRelationship.full_text}`);
}
}
// Event listeners for relationships
addRelationshipLabelBtn.addEventListener('click', addRelationshipLabel);
relationshipLabelInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
addRelationshipLabel();
}
});
extractRelationshipsBtn.addEventListener('click', extractRelationships);
// Event listeners for manual relationship creation
addRelationshipBtn.addEventListener('click', addNewRelationship);
newSubjectSelect.addEventListener('change', updateRelationshipForm);
newRelationSelect.addEventListener('change', updateRelationshipForm);
newObjectSelect.addEventListener('change', updateRelationshipForm);
// Initialize
loadDefaultLabels();
loadDefaultRelationshipLabels();
updateAnnotationsList();
</script>
</body>
</html>