/**
* Commander AI Lab — Deck Coach UI v28
* ═════════════════════════════════════
* Multi-turn chat with SSE streaming, apply suggestions,
* cards-like search, enhanced session history.
*/
(function () {
'use strict';
const API = window.location.origin;
// ── State ────────────────────────────────────────────────
let chatHistory = []; // [{role, content}]
let isStreaming = false;
let currentSessionId = null;
let sessionCache = {}; // sessionId → full session data
let expandedSessions = {}; // sessionId → bool
let dbDecks = []; // [{id, name}] from /api/decks
// ── Scryfall Card Hover Preview ─────────────────────────
const _scryfallImgCache = {};
let _previewEl = null;
function _ensurePreviewEl() {
if (_previewEl) return _previewEl;
_previewEl = document.createElement('div');
_previewEl.id = 'coach-card-preview';
_previewEl.style.display = 'none';
_previewEl.innerHTML = '';
document.body.appendChild(_previewEl);
return _previewEl;
}
function _showCardPreview(cardName, evt) {
const el = _ensurePreviewEl();
const img = el.querySelector('img');
if (_scryfallImgCache[cardName]) {
img.src = _scryfallImgCache[cardName];
} else {
img.src = '';
fetch('https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(cardName))
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
const url = (data.image_uris && data.image_uris.normal) || (data.card_faces && data.card_faces[0] && data.card_faces[0].image_uris && data.card_faces[0].image_uris.normal) || '';
_scryfallImgCache[cardName] = url;
if (el.style.display !== 'none') img.src = url;
}).catch(() => {});
}
el.style.display = 'block';
_positionPreview(evt);
}
function _positionPreview(evt) {
if (!_previewEl) return;
_previewEl.style.left = Math.max(0, Math.min(evt.clientX + 20, window.innerWidth - 260)) + 'px';
_previewEl.style.top = Math.max(0, Math.min(evt.clientY - 150, window.innerHeight - 370)) + 'px';
}
function _hideCardPreview() {
if (_previewEl) _previewEl.style.display = 'none';
}
function _attachCardHover(container) {
container.querySelectorAll('td[data-card-name]').forEach(td => {
td.addEventListener('mouseenter', e => _showCardPreview(td.dataset.cardName, e));
td.addEventListener('mousemove', e => _positionPreview(e));
td.addEventListener('mouseleave', _hideCardPreview);
});
}
// ── DOM References ──────────────────────────────────────
const deckSelect = document.getElementById('coach-deck-select');
const powerSlider = document.getElementById('coach-power-level');
const powerValue = document.getElementById('coach-power-value');
const metaFocus = document.getElementById('coach-meta-focus');
const budgetSelect = document.getElementById('coach-budget');
const runBtn = document.getElementById('coach-run-btn');
const runHint = document.getElementById('coach-run-hint');
const progressEl = document.getElementById('coach-progress');
const progressText = document.getElementById('coach-progress-text');
const resultsEl = document.getElementById('coach-results');
// Status
const statusLlmDot = document.querySelector('#status-llm .status-dot');
const statusLlmValue = document.getElementById('status-llm-value');
const statusEmbDot = document.querySelector('#status-embeddings .status-dot');
const statusEmbValue = document.getElementById('status-embeddings-value');
const statusRepDot = document.querySelector('#status-reports .status-dot');
const statusRepValue = document.getElementById('status-reports-value');
// Results
const summaryText = document.getElementById('coach-summary-text');
const metaRow = document.getElementById('coach-meta-row');
const cutsCount = document.getElementById('coach-cuts-count');
const cutsBody = document.getElementById('coach-cuts-body');
const cutsAllCheck = document.getElementById('coach-cuts-all');
const addsCount = document.getElementById('coach-adds-count');
const addsBody = document.getElementById('coach-adds-body');
const addsAllCheck = document.getElementById('coach-adds-all');
const hintsCard = document.getElementById('coach-hints-card');
const hintsList = document.getElementById('coach-hints-list');
const manaCard = document.getElementById('coach-mana-card');
const manaText = document.getElementById('coach-mana-text');
const rawText = document.getElementById('coach-raw-text');
const sessionsList = document.getElementById('coach-sessions-list');
const genReportsBtn = document.getElementById('generate-reports-btn');
// Chat
const chatMessages = document.getElementById('coach-chat-messages');
const chatInput = document.getElementById('coach-chat-input');
const chatSendBtn = document.getElementById('coach-chat-send');
const chatClearBtn = document.getElementById('coach-chat-clear');
// Cards-Like
const cardslikeInput = document.getElementById('cardslike-input');
const cardslikeTopn = document.getElementById('cardslike-topn');
const cardslikeSearch = document.getElementById('cardslike-search-btn');
const cardslikeResults = document.getElementById('cardslike-results');
const cardslikeHeader = document.getElementById('cardslike-header');
const cardslikeBody = document.getElementById('cardslike-body');
const cardslikeArrow = document.getElementById('cardslike-arrow');
// Apply
const applyBtn = document.getElementById('coach-apply-btn');
const applyDeckSelect = document.getElementById('coach-apply-deck-select');
const applyCountEl = document.getElementById('coach-apply-count');
// ── Init ────────────────────────────────────────────────
init();
async function init() {
powerSlider.addEventListener('input', () => {
powerValue.textContent = powerSlider.value;
});
deckSelect.addEventListener('change', () => {
const hasDeck = deckSelect.value !== '';
runBtn.disabled = !hasDeck;
chatSendBtn.disabled = !hasDeck;
runHint.textContent = hasDeck ? 'Ready to analyze' : 'Select a deck to begin';
});
runBtn.addEventListener('click', runCoach);
genReportsBtn.addEventListener('click', generateReports);
cutsAllCheck.addEventListener('change', () => {
cutsBody.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = cutsAllCheck.checked;
});
updateApplyCount();
});
addsAllCheck.addEventListener('change', () => {
addsBody.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = addsAllCheck.checked;
});
updateApplyCount();
});
// Chat events
chatSendBtn.addEventListener('click', sendChatMessage);
chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChatMessage();
}
});
chatInput.addEventListener('input', () => {
chatSendBtn.disabled = !chatInput.value.trim() || !deckSelect.value;
});
chatClearBtn.addEventListener('click', clearChat);
// Cards-Like
cardslikeSearch.addEventListener('click', searchCardsLike);
cardslikeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchCardsLike();
});
cardslikeHeader.addEventListener('click', () => {
cardslikeBody.classList.toggle('collapsed');
cardslikeArrow.textContent = cardslikeBody.classList.contains('collapsed') ? '\u25B6' : '\u25BC';
});
// Apply
applyBtn.addEventListener('click', applySuggestions);
// Delegate checkbox changes in tables for apply count
document.getElementById('coach-cuts-card').addEventListener('change', updateApplyCount);
document.getElementById('coach-adds-card').addEventListener('change', updateApplyCount);
// Load data
await Promise.all([
checkStatus(),
loadDecks(),
loadSessions(),
loadDbDecks(),
]);
}
// ── Goals Helper ────────────────────────────────────────
function getGoals() {
const goals = {};
const pl = parseInt(powerSlider.value);
if (pl) goals.targetPowerLevel = pl;
if (metaFocus.value) goals.metaFocus = metaFocus.value;
if (budgetSelect.value) goals.budget = budgetSelect.value;
const focusAreas = [];
document.querySelectorAll('.coach-focus-tags input[type="checkbox"]:checked').forEach(cb => {
focusAreas.push(cb.value);
});
if (focusAreas.length > 0) goals.focusAreas = focusAreas;
return goals;
}
// ── Status Check ────────────────────────────────────────
async function checkStatus() {
try {
const res = await fetch(API + '/api/coach/status');
const data = await res.json();
if (data.llmConnected) {
setStatus(statusLlmDot, statusLlmValue, 'ok', data.llmModel || 'Connected');
} else {
setStatus(statusLlmDot, statusLlmValue, 'error', data.error || 'Not connected');
}
if (data.embeddingsLoaded) {
setStatus(statusEmbDot, statusEmbValue, 'ok', data.embeddingCards.toLocaleString() + ' cards');
} else {
setStatus(statusEmbDot, statusEmbValue, 'warn', 'Not loaded');
}
if (data.deckReportsAvailable > 0) {
setStatus(statusRepDot, statusRepValue, 'ok', data.deckReportsAvailable + ' available');
genReportsBtn.style.display = 'inline-block';
genReportsBtn.textContent = 'Rebuild Reports';
} else {
setStatus(statusRepDot, statusRepValue, 'warn', 'None \u2014 run simulations first');
genReportsBtn.style.display = 'inline-block';
genReportsBtn.textContent = 'Generate Reports';
}
} catch (e) {
setStatus(statusLlmDot, statusLlmValue, 'error', 'Service unavailable');
setStatus(statusEmbDot, statusEmbValue, 'error', '\u2014');
setStatus(statusRepDot, statusRepValue, 'error', '\u2014');
}
}
function setStatus(dotEl, valueEl, level, text) {
dotEl.className = 'status-dot status-' + level;
valueEl.textContent = text;
}
// ── Load Decks ──────────────────────────────────────────
async function loadDecks() {
try {
const res = await fetch(API + '/api/coach/decks');
const data = await res.json();
// Backend returns a bare array of objects, not {decks: [...]}
const decks = Array.isArray(data) ? data : (data.decks || []);
while (deckSelect.options.length > 1) {
deckSelect.remove(1);
}
if (decks.length === 0) {
const opt = document.createElement('option');
opt.disabled = true;
opt.textContent = 'No decks found \u2014 add decks in Deck Builder first';
deckSelect.appendChild(opt);
return;
}
// Sort by deck name
decks.sort((a, b) => {
const na = (typeof a === 'string' ? a : a.deck_name || '').toLowerCase();
const nb = (typeof b === 'string' ? b : b.deck_name || '').toLowerCase();
return na.localeCompare(nb);
});
for (const deck of decks) {
const opt = document.createElement('option');
if (typeof deck === 'string') {
// Legacy: bare string ID
opt.value = deck;
opt.textContent = deck;
} else {
// Object format: {deck_id, deck_name, commander, has_report, ...}
const slug = deck.deck_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
opt.value = slug;
opt.textContent = deck.deck_name + (deck.commander ? ' (' + deck.commander + ')' : '');
}
deckSelect.appendChild(opt);
}
} catch (e) {
console.error('Failed to load decks:', e);
}
}
// ── Load DB Decks (for Apply) ───────────────────────────
async function loadDbDecks() {
try {
const res = await fetch(API + '/api/decks');
if (!res.ok) return;
const data = await res.json();
dbDecks = data.decks || [];
while (applyDeckSelect.options.length > 1) {
applyDeckSelect.remove(1);
}
for (const deck of dbDecks) {
const opt = document.createElement('option');
opt.value = deck.id;
opt.textContent = deck.name + (deck.commander ? ' (' + deck.commander + ')' : '');
applyDeckSelect.appendChild(opt);
}
} catch (e) {
console.error('Failed to load DB decks:', e);
}
}
// ── Load Past Sessions ──────────────────────────────────
async function loadSessions() {
try {
const res = await fetch(API + '/api/coach/sessions');
const data = await res.json();
const sessions = data.sessions || [];
if (sessions.length === 0) {
sessionsList.innerHTML = '
No coaching sessions yet. Run the coach to get started.
'; return; } sessionsList.innerHTML = ''; for (const s of sessions) { const item = document.createElement('div'); item.className = 'coach-session-item'; item.dataset.sessionId = s.sessionId; const timeStr = s.timestamp ? formatTime(s.timestamp) : ''; const isExpanded = expandedSessions[s.sessionId]; item.innerHTML = '' + escHtml(session.summary) + '
'; } // Cuts if (cuts.length > 0) { html += 'Select a deck above and ask me anything about your strategy, card choices, or upgrades.
' + '$1')
.replace(/\n/g, '