Audio Backups: - New audio_backups table in PostgreSQL (bytea, gzip compressed) - POST /api/audio-backups — upload with gzip compression (level 6) - GET /api/audio-backups — list user's backups - GET /api/audio-backups/:id/audio — download decompressed audio - DELETE /api/audio-backups/:id — delete backup - Auto-cleanup every hour (24h expiry) - Frontend saves to server first, falls back to IndexedDB - Settings shows source badge (server/local) per backup AI Corrections: - Corrections list is now expandable — click to view original vs corrected - Shows red "Original" and green "Corrected to" sections - Click arrow to expand/collapse each correction - Date shown on each correction
261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
// ============================================================
|
|
// MEMORIES.JS — User template/memory storage + settings UI
|
|
// ============================================================
|
|
|
|
(function() {
|
|
|
|
var _memories = [];
|
|
var _editingId = null;
|
|
|
|
var CATEGORY_LABELS = {
|
|
physical_exam: 'Physical Exam',
|
|
ros: 'Review of Systems',
|
|
encounter_format: 'Encounter Format',
|
|
family_history: 'Family History',
|
|
assessment_plan: 'Assessment & Plan',
|
|
template_soap: 'SOAP Template',
|
|
template_hpi: 'HPI Template',
|
|
template_wellvisit: 'Well Visit Template',
|
|
template_sickvisit: 'Sick Visit Template',
|
|
custom: 'Custom'
|
|
};
|
|
|
|
// Correction categories (hidden from manual editing, managed by correction tracker)
|
|
var CORRECTION_LABELS = {
|
|
correction_soap: 'SOAP Correction',
|
|
correction_hpi: 'HPI Correction',
|
|
correction_encounter: 'Encounter Correction',
|
|
correction_wellvisit: 'Well Visit Correction',
|
|
correction_sickvisit: 'Sick Visit Correction'
|
|
};
|
|
|
|
// ── Load memories ────────────────────────────────────────────────────────
|
|
|
|
function loadMemories() {
|
|
fetch('/api/memories', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) return;
|
|
_memories = data.memories || [];
|
|
renderMemoryList();
|
|
})
|
|
.catch(function(err) { console.error('[Memories] Load failed:', err); });
|
|
}
|
|
|
|
function renderMemoryList() {
|
|
var container = document.getElementById('mem-list');
|
|
if (!container) return;
|
|
// Separate templates from corrections
|
|
var templates = _memories.filter(function(m) { return !m.category.startsWith('correction_'); });
|
|
var corrections = _memories.filter(function(m) { return m.category.startsWith('correction_'); });
|
|
// Render corrections list
|
|
renderCorrectionsList(corrections);
|
|
if (templates.length === 0) {
|
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No templates saved yet. Add one above.</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = templates.map(function(m) {
|
|
var catLabel = CATEGORY_LABELS[m.category] || m.category;
|
|
var preview = (m.content || '').substring(0, 100).replace(/\n/g, ' ');
|
|
return '<div class="mem-item" data-id="' + m.id + '">' +
|
|
'<div class="mem-item-info">' +
|
|
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
|
|
'<span class="mem-item-name">' + esc(m.name) + '</span>' +
|
|
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
|
|
'</div>' +
|
|
'<div class="mem-item-preview">' + esc(preview) + (m.content && m.content.length > 100 ? '...' : '') + '</div>' +
|
|
'</div>' +
|
|
'<button class="btn-sm btn-ghost mem-edit-btn" data-id="' + m.id + '" title="Edit"><i class="fas fa-pen"></i></button>' +
|
|
'<button class="btn-sm btn-ghost mem-delete-btn" data-id="' + m.id + '" style="color:var(--red);" title="Delete"><i class="fas fa-trash"></i></button>' +
|
|
'</div>';
|
|
}).join('');
|
|
|
|
container.querySelectorAll('.mem-edit-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() { editMemory(parseInt(btn.dataset.id)); });
|
|
});
|
|
container.querySelectorAll('.mem-delete-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function() { deleteMemory(parseInt(btn.dataset.id)); });
|
|
});
|
|
}
|
|
|
|
function renderCorrectionsList(corrections) {
|
|
var container = document.getElementById('corrections-list');
|
|
if (!container) return;
|
|
if (corrections.length === 0) {
|
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No corrections yet. Edit AI-generated notes and save to start learning.</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = corrections.map(function(m) {
|
|
var catLabel = CORRECTION_LABELS[m.category] || m.category;
|
|
var preview = (m.name || '').replace(/\n/g, ' ');
|
|
// Parse original and corrected from content
|
|
var parts = parseCorrection(m.content || '');
|
|
var date = m.created_at ? new Date(m.created_at).toLocaleDateString() : '';
|
|
return '<div class="mem-item" style="flex-direction:column;align-items:stretch;cursor:pointer;" data-id="' + m.id + '">' +
|
|
'<div style="display:flex;align-items:center;gap:8px;" class="correction-header" data-toggle="' + m.id + '">' +
|
|
'<i class="fas fa-chevron-right correction-arrow" id="arrow-' + m.id + '" style="font-size:10px;color:var(--g400);transition:transform 0.2s;"></i>' +
|
|
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
|
|
'<span style="flex:1;font-size:12px;color:var(--g600);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(preview) + '</span>' +
|
|
'<span style="font-size:11px;color:var(--g400);flex-shrink:0;">' + date + '</span>' +
|
|
'<button class="btn-sm btn-ghost correction-delete-btn" data-id="' + m.id + '" style="color:var(--red);flex-shrink:0;" title="Delete"><i class="fas fa-trash"></i></button>' +
|
|
'</div>' +
|
|
'<div class="correction-detail hidden" id="detail-' + m.id + '" style="margin-top:8px;padding:8px 12px;background:var(--g50);border-radius:6px;font-size:12px;line-height:1.6;">' +
|
|
'<div style="margin-bottom:8px;">' +
|
|
'<div style="font-weight:600;color:var(--red);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Original (AI generated):</div>' +
|
|
'<div style="color:var(--g600);white-space:pre-wrap;font-family:inherit;">' + esc(parts.original) + '</div>' +
|
|
'</div>' +
|
|
'<div>' +
|
|
'<div style="font-weight:600;color:var(--green);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Corrected to:</div>' +
|
|
'<div style="color:var(--g800);white-space:pre-wrap;font-family:inherit;">' + esc(parts.corrected) + '</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
|
|
// Toggle expand/collapse
|
|
container.querySelectorAll('.correction-header').forEach(function(header) {
|
|
header.addEventListener('click', function(e) {
|
|
if (e.target.closest('.correction-delete-btn')) return;
|
|
var id = header.dataset.toggle;
|
|
var detail = document.getElementById('detail-' + id);
|
|
var arrow = document.getElementById('arrow-' + id);
|
|
if (detail) {
|
|
detail.classList.toggle('hidden');
|
|
if (arrow) arrow.style.transform = detail.classList.contains('hidden') ? '' : 'rotate(90deg)';
|
|
}
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll('.correction-delete-btn').forEach(function(btn) {
|
|
btn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
deleteMemory(parseInt(btn.dataset.id));
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseCorrection(content) {
|
|
var original = '';
|
|
var corrected = '';
|
|
var idx = content.indexOf('\nCORRECTED TO: ');
|
|
if (idx !== -1) {
|
|
original = content.substring(0, idx).replace(/^ORIGINAL:\s*/i, '');
|
|
corrected = content.substring(idx + '\nCORRECTED TO: '.length);
|
|
} else {
|
|
original = content;
|
|
}
|
|
return { original: original.trim(), corrected: corrected.trim() };
|
|
}
|
|
|
|
// ── Save memory ──────────────────────────────────────────────────────────
|
|
|
|
function saveMemory() {
|
|
var name = document.getElementById('mem-name').value.trim();
|
|
var category = document.getElementById('mem-category').value;
|
|
var content = document.getElementById('mem-content').value.trim();
|
|
|
|
if (!name) { showToast('Enter a template name', 'error'); return; }
|
|
if (!content) { showToast('Enter template content', 'error'); return; }
|
|
|
|
var url = _editingId ? '/api/memories/' + _editingId : '/api/memories';
|
|
var method = _editingId ? 'PUT' : 'POST';
|
|
|
|
fetch(url, {
|
|
method: method,
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ name: name, category: category, content: content })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) {
|
|
showToast(_editingId ? 'Template updated' : 'Template saved', 'success');
|
|
cancelEdit();
|
|
loadMemories();
|
|
} else {
|
|
showToast(data.error || 'Save failed', 'error');
|
|
}
|
|
})
|
|
.catch(function() { showToast('Save failed', 'error'); });
|
|
}
|
|
|
|
function editMemory(id) {
|
|
var mem = _memories.find(function(m) { return m.id === id; });
|
|
if (!mem) return;
|
|
_editingId = id;
|
|
document.getElementById('mem-name').value = mem.name;
|
|
document.getElementById('mem-category').value = mem.category;
|
|
document.getElementById('mem-content').value = mem.content;
|
|
|
|
var btn = document.getElementById('btn-mem-save');
|
|
if (btn) btn.innerHTML = '<i class="fas fa-check"></i> Update Template';
|
|
|
|
// Add cancel button if not present
|
|
if (!document.getElementById('btn-mem-cancel')) {
|
|
var cancel = document.createElement('button');
|
|
cancel.id = 'btn-mem-cancel';
|
|
cancel.className = 'btn-sm btn-ghost';
|
|
cancel.innerHTML = '<i class="fas fa-times"></i> Cancel';
|
|
cancel.style.marginLeft = '6px';
|
|
cancel.addEventListener('click', cancelEdit);
|
|
if (btn && btn.parentNode) btn.parentNode.appendChild(cancel);
|
|
}
|
|
document.getElementById('mem-name').focus();
|
|
}
|
|
|
|
function cancelEdit() {
|
|
_editingId = null;
|
|
document.getElementById('mem-name').value = '';
|
|
document.getElementById('mem-content').value = '';
|
|
var btn = document.getElementById('btn-mem-save');
|
|
if (btn) btn.innerHTML = '<i class="fas fa-plus"></i> Add Template';
|
|
var cancel = document.getElementById('btn-mem-cancel');
|
|
if (cancel) cancel.remove();
|
|
}
|
|
|
|
function deleteMemory(id) {
|
|
var mem = _memories.find(function(m) { return m.id === id; });
|
|
if (!confirm('Delete template "' + (mem ? mem.name : '') + '"?')) return;
|
|
fetch('/api/memories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) { showToast('Template deleted', 'info'); loadMemories(); }
|
|
else showToast(data.error || 'Delete failed', 'error');
|
|
})
|
|
.catch(function() { showToast('Delete failed', 'error'); });
|
|
}
|
|
|
|
// ── Event wiring ─────────────────────────────────────────────────────────
|
|
|
|
// Expose so auth.js can call on settings tab navigation
|
|
window.loadMemories = loadMemories;
|
|
|
|
document.addEventListener('click', function(e) {
|
|
// Settings button or settings tab — load memories
|
|
if (e.target.closest('#btn-settings') || (e.target.closest('.tab-btn') && e.target.closest('.tab-btn[data-tab="settings"]'))) {
|
|
setTimeout(loadMemories, 100);
|
|
}
|
|
// Save memory button
|
|
if (e.target.closest('#btn-mem-save')) {
|
|
saveMemory();
|
|
}
|
|
});
|
|
|
|
// ── Expose context for AI generation ────────────────────────────────────
|
|
|
|
// Call this before any AI generation to get user memory context.
|
|
// Always fetches from the API so it works even if _memories cache is empty.
|
|
window.getUserMemoryContext = function() {
|
|
return fetch('/api/memories/context', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) { return data.success ? (data.context || '') : ''; })
|
|
.catch(function() { return ''; });
|
|
};
|
|
|
|
function esc(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
console.log('✅ Memories module loaded');
|
|
|
|
})();
|