mstepien's picture
Feat: Add demo data button
71f8eda
function dermatologApp() {
return {
// App State
activeTab: 'photos',
analysisResults: {},
showTechnicalDetails: {}, // Map of photo_id -> boolean
disclaimerAccepted: false,
// Chat State
prompt: '',
temperature: 0.2,
loading: false,
loadingDemo: false,
response: null,
latency: null,
sessionId: null,
// Photo State
timeline: [],
dragover: false,
editingPhoto: null,
editingDate: '',
// Common
error: null,
modelName: 'Loading...',
yoloAvailable: false,
marginThreshold: 0.05,
currentAnalysisId: null,
clearPromise: null,
debugMode: false,
init() {
this.sessionId = this.getCookie('session_id');
const urlParams = new URLSearchParams(window.location.search);
this.debugMode = urlParams.has('debug');
// Check disclaimer status
this.disclaimerAccepted = localStorage.getItem('dermatolog_disclaimer_accepted') === 'true';
if (!this.disclaimerAccepted) {
setTimeout(() => {
const dialog = document.querySelector('.disclaimer-dialog');
if (dialog) dialog.show();
}, 500);
}
this.loadTimeline();
this.fetchModelInfo();
// Global Paste Handler
window.addEventListener('paste', (e) => {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
const files = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0) {
this.handleFiles(files);
}
});
// Prevent accidental refresh
window.addEventListener('beforeunload', (e) => {
if (this.timeline && this.timeline.length > 0) {
const msg = "On refresh the content would be cleared. Are you sure you want to leave?";
e.preventDefault();
e.returnValue = msg;
return msg;
}
});
},
async fetchModelInfo() {
try {
const res = await fetch('/api/health');
if (res.ok) {
const data = await res.json();
this.yoloAvailable = data.yolo_available;
if (data.status === "OK") {
this.modelName = "MedSigLIP (Local)";
} else if (data.status === "suspended") {
this.modelName = "Service Suspended";
} else {
this.modelName = data.status || "Unknown Status";
}
}
} catch (e) {
console.error("Failed to fetch model info", e);
this.modelName = "Error fetching health";
}
},
getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
},
async loadTimeline() {
try {
const res = await fetch('/api/photos?t=' + new Date().getTime());
if (res.ok) {
this.timeline = await res.json();
this.timeline.forEach(item => {
const processPhoto = (p) => {
// Status is handled at runtime in this.analysisResults, not restored from DB
// satisfy "store the state whether image was processed in the html not db"
};
if (item.type === 'directory') {
item.items.forEach(processPhoto);
} else if (item.type === 'photo') {
processPhoto(item.data);
}
});
}
} catch (e) {
console.error("Timeline load failed", e);
}
},
async loadDemoData() {
if (this.loadingDemo) return;
this.loadingDemo = true;
try {
const metadataRes = await fetch('/api/demo-data');
if (!metadataRes.ok) throw new Error("Failed to fetch demo metadata");
const demoItems = await metadataRes.json();
const files = [];
for (const item of demoItems) {
try {
if (!item.base64_data) continue;
// Convert base64 to Blob
const byteCharacters = atob(item.base64_data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: item.mime_type });
const filename = item.filename || 'demo_image.jpg';
files.push(new File([blob], filename, { type: blob.type }));
} catch (err) {
console.error("Error creating demo image:", item.filename, err);
}
}
if (files.length > 0) {
await this.handleFiles(files);
this.showToast("Demo Data Loaded", `Successfully loaded ${files.length} demo images.`, "success", "info-circle");
} else {
this.showToast("Demo Data Failed", "Could not load any demo images.", "danger", "exclamation-triangle");
}
} catch (err) {
console.error("Critical error loading demo data", err);
} finally {
this.loadingDemo = false;
}
},
async handleDrop(event) {
this.dragover = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
this.handleFiles(files);
}
},
async handleFiles(files) {
if (files.length === 0) return;
// Ensure we wait for any ongoing session clearing to finish
if (this.clearPromise) {
await this.clearPromise;
}
this.loading = true;
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const dataUrl = await this.readAsDataURL(file);
// Basic duplicate check (by name and size for local)
const isDuplicate = this.getAllPhotos().some(p => p.filename === file.name && p.size === file.size);
if (isDuplicate) {
this.showToast("Upload Notice", `Skipped ${file.name} (already in timeline)`, "warning");
continue;
}
const photoId = crypto.randomUUID();
const photo = {
id: photoId,
filename: file.name,
size: file.size,
creation_date: new Date(file.lastModified || Date.now()).toISOString().split('T')[0],
uploaded_at: new Date().toISOString(),
local_content: dataUrl,
analysis: null
};
this.addPhotoToTimeline(photo);
}
// Brief delay to let UI render the new cards
setTimeout(() => {
this.analyzeAllPhotos();
}, 300);
} catch (e) {
console.error("Local processing error:", e);
this.error = "Failed to process images: " + e.message;
} finally {
this.loading = false;
}
},
readAsDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
},
addPhotoToTimeline(photo) {
// Check if day exists
let dir = this.timeline.find(item => item.type === 'directory' && item.date === photo.creation_date);
if (!dir) {
dir = {
type: 'directory',
date: photo.creation_date,
items: [],
count: 0
};
this.timeline.push(dir);
// Sort timeline by date descending
this.timeline.sort((a, b) => b.date.localeCompare(a.date));
}
// Avoid duplicates in items list
if (!dir.items.some(p => p.id === photo.id)) {
dir.items.push(photo);
dir.items.sort((a, b) => b.uploaded_at.localeCompare(a.uploaded_at));
dir.count = dir.items.length;
}
},
async deletePhoto(photoId) {
if (!confirm("Are you sure you want to delete this photo locally?")) return;
// Remove from timeline state (purely local)
this.timeline.forEach(dir => {
if (dir.type === 'directory') {
dir.items = dir.items.filter(p => p.id !== photoId);
dir.count = dir.items.length;
}
});
// Clean up empty directories
this.timeline = this.timeline.filter(dir => dir.type !== 'directory' || dir.count > 0);
delete this.analysisResults[photoId];
return true;
},
async deletePhotoFromModal() {
if (!this.editingPhoto) return;
const success = await this.deletePhoto(this.editingPhoto.id);
if (success) {
document.querySelector('.edit-dialog').hide();
this.editingPhoto = null;
}
},
async clearSession() {
if (!confirm("Clear all local photos?")) return;
// reset all frontend reactive state variables needed for a clean run
this.analysisResults = {};
this.timeline = [];
this.showTechnicalDetails = {};
this.prompt = '';
this.loading = false;
this.response = null;
this.latency = null;
this.currentAnalysisId = null;
this.editingPhoto = null;
this.editingDate = '';
// Reset file inputs so identical files can trigger @change again
if (this.$refs.fileInput) this.$refs.fileInput.value = '';
if (this.$refs.cameraInput) this.$refs.cameraInput.value = '';
// Optional: Tell backend to clear its session context if needed
fetch('/api/photos', { method: 'DELETE' }).catch(console.error);
},
acceptDisclaimer() {
this.disclaimerAccepted = true;
localStorage.setItem('dermatolog_disclaimer_accepted', 'true');
document.querySelector('.disclaimer-dialog').hide();
},
openEditModal(photo) {
this.editingPhoto = photo;
this.editingDate = photo.creation_date;
document.querySelector('.edit-dialog').show();
},
async saveDate() {
if (!this.editingPhoto) return;
// Update locally
const oldDate = this.editingPhoto.creation_date;
const newDate = this.editingDate;
if (oldDate !== newDate) {
// Remove from old location
this.timeline.forEach(dir => {
if (dir.date === oldDate) {
dir.items = dir.items.filter(p => p.id !== this.editingPhoto.id);
dir.count = dir.items.length;
}
});
// Add to new
this.editingPhoto.creation_date = newDate;
this.addPhotoToTimeline(this.editingPhoto);
// Cleanup empty
this.timeline = this.timeline.filter(dir => dir.count > 0);
}
document.querySelector('.edit-dialog').hide();
this.editingPhoto = null;
},
async analyzeAllPhotos() {
this.loading = true;
this.error = null;
this.latency = 0;
const photos = this.getAllPhotos();
if (photos.length === 0) {
this.loading = false;
return;
}
const photosToAnalyze = photos.filter(p => !this.analysisResults[p.id]);
if (photosToAnalyze.length === 0) {
this.loading = false;
return;
}
let report = (this.response || "");
if (report && !report.endsWith("\n\n")) report += "\n\n";
report += `--- Starting Local Analysis Batch [${new Date().toLocaleTimeString()}] ---\n`;
this.response = report;
let startTime = performance.now();
try {
for (const photo of photosToAnalyze) {
console.log(`Starting analysis for ${photo.filename} (${photo.id})`);
report += `Analyzing ${photo.filename} (Local Transfer)...\n`;
this.response = report;
this.currentAnalysisId = photo.id;
try {
const res = await fetch(`/api/photos/${photo.id}/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'medsiglip',
margin_threshold: parseFloat(this.marginThreshold),
base64_image: photo.local_content
})
});
if (res.ok) {
const data = await res.json();
if (data.predictions && data.predictions.length > 0) {
// Populate results for UI
this.analysisResults[photo.id] = {
id: photo.id,
date: new Date().toISOString(),
prediction: data.predictions[0],
predictions: data.predictions, // For legacy if any
primary: data.predictions,
initial_classification: data.initial_classification,
primary_name: data.primary_model_name,
interpretation: data.interpretation,
preprocess_strategy: data.preprocess_strategy,
prepared_image_base64: data.prepared_image_base64,
execution_times: data.execution_times,
saliency_base64: data.saliency_base64
};
report += ` ➔ Primary Results (${data.primary_model_name}):\n`;
data.predictions.forEach(p => {
report += ` - ${p.label}: ${(p.score * 100).toFixed(1)}%\n`;
});
}
} else {
const err = await res.text();
report += ` ➔ Request Failed: ${res.status} ${err}\n`;
}
} catch (e) {
console.error(`Analysis error for ${photo.id}:`, e);
report += ` ➔ Error: ${e.message}\n`;
}
this.currentAnalysisId = null;
report += "\n";
this.response = report;
}
this.latency = Math.round(performance.now() - startTime);
report += "Batch Completion Success.";
this.response = report;
} catch (e) {
console.error(e);
this.error = "Analysis process encountered a critical error.";
} finally {
this.loading = false;
}
},
async fetchSaliency(photo) {
console.log("fetchSaliency triggered for", photo.id);
if (!this.analysisResults[photo.id]) {
console.warn("No analysis results for photo", photo.id);
return;
}
if (this.analysisResults[photo.id].saliency_base64) {
console.log("Saliency already exists for", photo.id);
return;
}
if (!this.analysisResults[photo.id].primary || this.analysisResults[photo.id].primary.length === 0) {
console.warn("No primary assessment predictions for", photo.id);
return;
}
const topLabel = this.analysisResults[photo.id].primary[0].label;
console.log("Fetching saliency for label:", topLabel);
try {
const res = await fetch(`/api/photos/${photo.id}/saliency`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
base64_image: photo.local_content,
target_label: topLabel
})
});
if (res.ok) {
const data = await res.json();
console.log("Saliency data received for", photo.id, "len:", data.saliency_base64 ? data.saliency_base64.length : 0);
this.analysisResults[photo.id].saliency_base64 = data.saliency_base64;
console.log("Updated analysisResults with saliency for", photo.id);
} else {
console.error("Saliency fetch failed with status:", res.status);
}
} catch (e) {
console.error("Saliency fetch error:", e);
}
},
getAllPhotos() {
let photos = [];
this.timeline.forEach(item => {
if (item.type === 'photo') photos.push(item.data);
else if (item.type === 'directory') photos.push(...item.items);
});
return photos;
},
getInterpretationColor(hint) {
const colors = {
'red': 'var(--sl-color-danger-600)',
'yellow': 'var(--sl-color-warning-600)',
'green': 'var(--sl-color-success-600)',
'gray': 'var(--sl-color-neutral-600)'
};
return colors[hint] || colors['gray'];
},
getBadgeVariant(hint) {
const variants = {
'green': 'success',
'gray': 'neutral',
'yellow': 'warning',
'red': 'danger'
};
return variants[hint] || 'neutral';
},
getInterpretationIcon(hint) {
if (hint === 'green') return 'shield-check';
if (hint === 'red') return 'exclamation-triangle';
return 'activity';
},
copyReport(photoId) {
const result = this.analysisResults[photoId];
if (!result) return;
const annotation = result.interpretation ? result.interpretation.annotation : result.prediction.label;
const confidence = result.interpretation ? result.interpretation.confidence_label : 'N/A';
const score = Math.round(result.prediction.score * 100) + '%';
const text = `Clinical Summary\n----------------\nResult: ${annotation}\nConfidence: ${confidence} (${score})\nDate: ${new Date(result.date).toLocaleString()}\n\nNote: This is an AI-assisted analysis and should be reviewed by a professional.`;
navigator.clipboard.writeText(text).then(() => {
this.showToast('Copied', 'Clinical summary copied to clipboard', 'success', 'clipboard-check');
});
},
toggleDebug() {
this.debugMode = !this.debugMode;
const url = new URL(window.location.href);
if (this.debugMode) {
url.searchParams.set('debug', '1');
} else {
url.searchParams.delete('debug');
}
window.history.replaceState({}, '', url.toString());
},
showToast(title, message, variant = 'primary', icon = 'info-circle') {
const alert = Object.assign(document.createElement('sl-alert'), {
variant: variant,
closable: true,
duration: 5000,
innerHTML: `
<sl-icon slot="icon" name="${icon}"></sl-icon>
<strong>${title}</strong><br />
${message}
`
});
document.body.append(alert);
// Ensure shoelace components are defined before calling methods
if (typeof customElements !== 'undefined' && customElements.whenDefined) {
customElements.whenDefined('sl-alert').then(() => {
if (typeof alert.toast === 'function') {
alert.toast();
}
});
} else {
// Fallback for environments where customElements/Shoelace might not be fully loaded
setTimeout(() => {
if (typeof alert.toast === 'function') alert.toast();
}, 100);
}
}
}
}
if (typeof window !== 'undefined') {
window.dermatologApp = dermatologApp;
}