* feat(coach): Phase 3/5 JSON schema + QUICK_DIGEST prompt (issue #108) - Append removalCoverage, rampQuality, drawEngineProfile, winConditions, antiSynergyFlags, podPresence, tempoAssessment, metaMatchups to schema - Add 8 new RULES for Phase 3/5 analysis - Add QUICK_DIGEST_SYSTEM_PROMPT constant - Add build_quick_system_prompt() function * feat(coach): Phase 3/5 JSON parsing + run_quick_digest() (issue #108) * feat(routes): add POST /api/coach/decks/{deck_id}/quick endpoint (issue #108) * feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108) * feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108) - Import RemovalCoverage, RampQuality, DrawEngineProfile, WinCondition, AntiSynergyFlag, PodPresence, TempoAssessment, MetaMatchup - Add Phase 3/5 blocks to _populate_session_from_json() - Add run_quick_digest() method * feat(ui): Top Priority card + Quick/Deep buttons in coach.html (issue #108) * feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108) * feat(ui): wire Quick Digest button + displayTopPriority() (issue #108)
1012 lines
45 KiB
JavaScript
1012 lines
45 KiB
JavaScript
/**
|
|
* Commander AI Lab — Deck Coach UI v29
|
|
* ═════════════════════════════════════
|
|
* Multi-turn chat with SSE streaming, apply suggestions,
|
|
* cards-like search, enhanced session history.
|
|
* v29: Quick Digest button + Top Priority Swap card.
|
|
*/
|
|
|
|
(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 = '<img />';
|
|
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('[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 quickBtn = document.getElementById('coach-quick-btn');
|
|
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');
|
|
const topPriorityCard = document.getElementById('coach-top-priority');
|
|
const topPriorityContent = document.getElementById('coach-top-priority-content');
|
|
|
|
// 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 !== '';
|
|
quickBtn.disabled = !hasDeck;
|
|
runBtn.disabled = !hasDeck;
|
|
chatSendBtn.disabled = !hasDeck;
|
|
runHint.textContent = hasDeck ? 'Ready to analyze' : 'Select a deck to begin';
|
|
});
|
|
|
|
quickBtn.addEventListener('click', runQuickDigest);
|
|
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();
|
|
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;
|
|
}
|
|
|
|
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') {
|
|
opt.value = deck;
|
|
opt.textContent = deck;
|
|
} else {
|
|
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 = '<p class="coach-empty">No coaching sessions yet. Run the coach to get started.</p>';
|
|
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];
|
|
const quickBadge = s.sessionId && s.sessionId.startsWith('quick-')
|
|
? '<span class="coach-badge-quick" title="Quick Digest">\u26A1 Quick</span>'
|
|
: '';
|
|
|
|
item.innerHTML =
|
|
'<div class="coach-session-header" data-sid="' + escHtml(s.sessionId) + '">' +
|
|
'<span class="coach-session-expand-arrow">' + (isExpanded ? '\u25BC' : '\u25B6') + '</span>' +
|
|
'<span class="coach-session-deck">' + escHtml(s.deckId) + quickBadge + '</span>' +
|
|
'<span class="coach-session-summary">' + escHtml(s.summary || '(no summary)') + '</span>' +
|
|
'<span class="coach-session-meta">' +
|
|
'<span class="coach-session-cuts">-' + (s.cutsCount || 0) + '</span>' +
|
|
'<span class="coach-session-adds">+' + (s.addsCount || 0) + '</span>' +
|
|
'<span class="coach-session-time">' + escHtml(timeStr) + '</span>' +
|
|
'</span>' +
|
|
'</div>' +
|
|
'<div class="coach-session-detail" id="session-detail-' + escHtml(s.sessionId) + '" style="' + (isExpanded ? '' : 'display:none') + '">' +
|
|
'<div class="coach-session-detail-loading">Loading...</div>' +
|
|
'</div>';
|
|
|
|
item.querySelector('.coach-session-header').addEventListener('click', function () {
|
|
toggleSession(s.sessionId);
|
|
});
|
|
|
|
sessionsList.appendChild(item);
|
|
|
|
if (isExpanded && sessionCache[s.sessionId]) {
|
|
renderSessionDetail(s.sessionId, sessionCache[s.sessionId]);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load sessions:', e);
|
|
}
|
|
}
|
|
|
|
async function toggleSession(sessionId) {
|
|
const detailEl = document.getElementById('session-detail-' + sessionId);
|
|
const headerEl = detailEl?.previousElementSibling;
|
|
const arrowEl = headerEl?.querySelector('.coach-session-expand-arrow');
|
|
if (!detailEl) return;
|
|
|
|
const wasHidden = detailEl.style.display === 'none';
|
|
if (wasHidden) {
|
|
detailEl.style.display = 'block';
|
|
expandedSessions[sessionId] = true;
|
|
if (arrowEl) arrowEl.textContent = '\u25BC';
|
|
if (!sessionCache[sessionId]) {
|
|
try {
|
|
const res = await fetch(API + '/api/coach/sessions/' + encodeURIComponent(sessionId));
|
|
if (!res.ok) throw new Error('Session not found');
|
|
sessionCache[sessionId] = await res.json();
|
|
renderSessionDetail(sessionId, sessionCache[sessionId]);
|
|
} catch (e) {
|
|
detailEl.innerHTML = '<div class="coach-session-detail-error">Failed to load session details</div>';
|
|
}
|
|
} else {
|
|
renderSessionDetail(sessionId, sessionCache[sessionId]);
|
|
}
|
|
} else {
|
|
detailEl.style.display = 'none';
|
|
expandedSessions[sessionId] = false;
|
|
if (arrowEl) arrowEl.textContent = '\u25B6';
|
|
}
|
|
}
|
|
|
|
function renderSessionDetail(sessionId, session) {
|
|
const detailEl = document.getElementById('session-detail-' + sessionId);
|
|
if (!detailEl) return;
|
|
|
|
const cuts = session.suggestedCuts || [];
|
|
const adds = session.suggestedAdds || [];
|
|
|
|
let html = '<div class="coach-session-detail-inner">';
|
|
if (session.summary) {
|
|
html += '<p class="coach-session-detail-summary">' + escHtml(session.summary) + '</p>';
|
|
}
|
|
if (cuts.length > 0) {
|
|
html += '<div class="coach-session-detail-group"><span class="coach-session-detail-label">Cuts:</span><div class="coach-session-pills">';
|
|
for (const cut of cuts) {
|
|
html += '<span class="coach-pill coach-pill-cut" data-card-name="' + escHtml(cut.cardName) + '" title="' + escHtml(cut.reason || '') + '">' + escHtml(cut.cardName) + '</span>';
|
|
}
|
|
html += '</div></div>';
|
|
}
|
|
if (adds.length > 0) {
|
|
html += '<div class="coach-session-detail-group"><span class="coach-session-detail-label">Adds:</span><div class="coach-session-pills">';
|
|
for (const add of adds) {
|
|
html += '<span class="coach-pill coach-pill-add" data-card-name="' + escHtml(add.cardName) + '" title="' + escHtml(add.reason || '') + '">' + escHtml(add.cardName) + '</span>';
|
|
}
|
|
html += '</div></div>';
|
|
}
|
|
html += '<div class="coach-session-detail-actions">';
|
|
html += '<button class="btn btn-ghost btn-sm coach-load-chat-btn" data-sid="' + escHtml(sessionId) + '">Load in Chat</button>';
|
|
html += '<button class="btn btn-ghost btn-sm coach-view-full-btn" data-sid="' + escHtml(sessionId) + '">View Full Results</button>';
|
|
html += '</div></div>';
|
|
detailEl.innerHTML = html;
|
|
_attachCardHover(detailEl);
|
|
|
|
detailEl.querySelector('.coach-load-chat-btn')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
loadSessionInChat(sessionId);
|
|
});
|
|
detailEl.querySelector('.coach-view-full-btn')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
displaySession(sessionCache[sessionId]);
|
|
resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
});
|
|
}
|
|
|
|
function loadSessionInChat(sessionId) {
|
|
const session = sessionCache[sessionId];
|
|
if (!session) return;
|
|
clearChat();
|
|
if (session.deckId) {
|
|
for (let i = 0; i < deckSelect.options.length; i++) {
|
|
if (deckSelect.options[i].value === session.deckId) {
|
|
deckSelect.value = session.deckId;
|
|
deckSelect.dispatchEvent(new Event('change'));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const contextMsg = 'Based on a previous coaching session for this deck, here was the analysis:\n\n' +
|
|
(session.summary || '') + '\n\n' +
|
|
'Suggested cuts: ' + (session.suggestedCuts || []).map(c => c.cardName).join(', ') + '\n' +
|
|
'Suggested adds: ' + (session.suggestedAdds || []).map(a => a.cardName).join(', ');
|
|
chatHistory.push({ role: 'assistant', content: contextMsg });
|
|
appendChatBubble('assistant', contextMsg);
|
|
chatInput.focus();
|
|
showToast('Session loaded into chat', 'success');
|
|
}
|
|
|
|
// ── Generate Reports ──────────────────────────────────
|
|
|
|
async function generateReports() {
|
|
genReportsBtn.disabled = true;
|
|
genReportsBtn.textContent = 'Generating...';
|
|
try {
|
|
const res = await fetch(API + '/api/coach/reports/generate', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.count > 0) {
|
|
genReportsBtn.textContent = data.count + ' reports generated';
|
|
await checkStatus();
|
|
await loadDecks();
|
|
} else {
|
|
genReportsBtn.textContent = 'No results found';
|
|
}
|
|
} catch (e) {
|
|
genReportsBtn.textContent = 'Error: ' + e.message;
|
|
} finally {
|
|
setTimeout(() => {
|
|
genReportsBtn.disabled = false;
|
|
genReportsBtn.textContent = 'Rebuild Reports';
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
// ── Quick Digest ───────────────────────────────────────
|
|
|
|
async function runQuickDigest() {
|
|
const deckId = deckSelect.value;
|
|
if (!deckId) return;
|
|
|
|
const goals = getGoals();
|
|
quickBtn.disabled = true;
|
|
runBtn.disabled = true;
|
|
progressEl.style.display = 'flex';
|
|
progressText.textContent = '\u26A1 Quick Digest running (~5s)...';
|
|
resultsEl.style.display = 'none';
|
|
|
|
const prevError = document.querySelector('.coach-error');
|
|
if (prevError) prevError.remove();
|
|
|
|
try {
|
|
const res = await fetch(API + '/api/coach/decks/' + encodeURIComponent(deckId) + '/quick', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ goals }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
|
|
throw new Error(err.detail || 'Quick digest failed (' + res.status + ')');
|
|
}
|
|
const session = await res.json();
|
|
currentSessionId = session.sessionId || null;
|
|
displaySession(session);
|
|
loadSessions();
|
|
showToast('Quick Digest complete!', 'success');
|
|
} catch (e) {
|
|
const errDiv = document.createElement('div');
|
|
errDiv.className = 'coach-error';
|
|
errDiv.textContent = 'Quick digest error: ' + e.message;
|
|
document.querySelector('.coach-config').appendChild(errDiv);
|
|
} finally {
|
|
quickBtn.disabled = false;
|
|
runBtn.disabled = false;
|
|
progressEl.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// ── Full Analysis ───────────────────────────────────────
|
|
|
|
async function runCoach() {
|
|
const deckId = deckSelect.value;
|
|
if (!deckId) return;
|
|
|
|
const goals = getGoals();
|
|
quickBtn.disabled = true;
|
|
runBtn.disabled = true;
|
|
progressEl.style.display = 'flex';
|
|
progressText.textContent = 'Analyzing deck and consulting LLM... This may take 30-60 seconds.';
|
|
resultsEl.style.display = 'none';
|
|
|
|
const prevError = document.querySelector('.coach-error');
|
|
if (prevError) prevError.remove();
|
|
|
|
try {
|
|
const res = await fetch(API + '/api/coach/decks/' + encodeURIComponent(deckId), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ goals }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: 'Unknown error' }));
|
|
throw new Error(err.detail || 'Coach request failed (' + res.status + ')');
|
|
}
|
|
const session = await res.json();
|
|
currentSessionId = session.sessionId || null;
|
|
displaySession(session);
|
|
loadSessions();
|
|
} catch (e) {
|
|
const errDiv = document.createElement('div');
|
|
errDiv.className = 'coach-error';
|
|
errDiv.textContent = 'Coach error: ' + e.message;
|
|
document.querySelector('.coach-config').appendChild(errDiv);
|
|
} finally {
|
|
quickBtn.disabled = false;
|
|
runBtn.disabled = false;
|
|
progressEl.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// ── Top Priority Swap ──────────────────────────────────
|
|
|
|
function displayTopPriority(upgradePriority) {
|
|
if (!topPriorityCard || !topPriorityContent) return;
|
|
if (!upgradePriority || upgradePriority.length === 0) {
|
|
topPriorityCard.style.display = 'none';
|
|
return;
|
|
}
|
|
// Show rank-1 item (or first if rank field missing)
|
|
const top = upgradePriority.find(u => u.rank === 1) || upgradePriority[0];
|
|
const impactClass = {
|
|
high: 'coach-impact-negative',
|
|
medium: 'coach-impact-neutral',
|
|
low: 'coach-impact-positive'
|
|
}[top.expectedImpact] || 'coach-impact-neutral';
|
|
|
|
topPriorityContent.innerHTML =
|
|
'<div class="coach-priority-swap">' +
|
|
'<div class="coach-priority-row">' +
|
|
'<span class="coach-priority-label">Cut</span>' +
|
|
'<span class="coach-priority-card coach-pill-cut" data-card-name="' + escHtml(top.cut) + '">' + escHtml(top.cut) + '</span>' +
|
|
'<span class="coach-priority-arrow">\u2192</span>' +
|
|
'<span class="coach-priority-label">Add</span>' +
|
|
'<span class="coach-priority-card coach-pill-add" data-card-name="' + escHtml(top.add) + '">' + escHtml(top.add) + '</span>' +
|
|
'<span class="coach-impact ' + impactClass + '">' + escHtml(top.expectedImpact || '') + '</span>' +
|
|
'</div>' +
|
|
(top.reasoning ? '<p class="coach-priority-reasoning">' + escHtml(top.reasoning) + '</p>' : '') +
|
|
'</div>';
|
|
|
|
_attachCardHover(topPriorityContent);
|
|
topPriorityCard.style.display = 'block';
|
|
}
|
|
|
|
// ── Display Session Results ──────────────────────────────
|
|
|
|
function displaySession(session) {
|
|
resultsEl.style.display = 'flex';
|
|
currentSessionId = session.sessionId || currentSessionId;
|
|
|
|
summaryText.textContent = session.summary || '(No summary provided)';
|
|
|
|
metaRow.innerHTML = '';
|
|
if (session.modelUsed) addMetaChip('Model', session.modelUsed);
|
|
if (session.promptTokens) addMetaChip('Prompt', session.promptTokens.toLocaleString() + ' tokens');
|
|
if (session.completionTokens) addMetaChip('Completion', session.completionTokens.toLocaleString() + ' tokens');
|
|
if (session.deckId) addMetaChip('Deck', session.deckId);
|
|
if (session.timestamp) addMetaChip('Time', formatTime(session.timestamp));
|
|
if (session.isQuickDigest) addMetaChip('Mode', '\u26A1 Quick Digest');
|
|
|
|
// Top Priority Swap
|
|
displayTopPriority(session.upgradePriority || []);
|
|
|
|
// Cuts table
|
|
const cuts = session.suggestedCuts || [];
|
|
cutsCount.textContent = cuts.length;
|
|
cutsBody.innerHTML = '';
|
|
cutsAllCheck.checked = false;
|
|
for (const cut of cuts) {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML =
|
|
'<td><input type="checkbox" data-card="' + escHtml(cut.cardName) + '" /></td>' +
|
|
'<td><span class="coach-card-name">' + escHtml(cut.cardName) + '</span></td>' +
|
|
'<td>' + escHtml(cut.reason) + '</td>' +
|
|
'<td><div class="coach-replacements">' +
|
|
(cut.replacementOptions || []).map(r =>
|
|
'<span class="coach-replacement-chip" data-card-name="' + escHtml(r) + '">' + escHtml(r) + '</span>'
|
|
).join('') +
|
|
'</div></td>' +
|
|
'<td>' + formatImpact(cut.currentImpactScore) + '</td>';
|
|
tr.querySelector('.coach-card-name').parentElement.dataset.cardName = cut.cardName;
|
|
cutsBody.appendChild(tr);
|
|
}
|
|
|
|
// Adds table
|
|
const adds = session.suggestedAdds || [];
|
|
addsCount.textContent = adds.length;
|
|
addsBody.innerHTML = '';
|
|
addsAllCheck.checked = false;
|
|
for (const add of adds) {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML =
|
|
'<td><input type="checkbox" data-card="' + escHtml(add.cardName) + '" /></td>' +
|
|
'<td><span class="coach-card-name">' + escHtml(add.cardName) + '</span></td>' +
|
|
'<td>' + (add.role ? '<span class="coach-role-badge">' + escHtml(add.role) + '</span>' : '') + '</td>' +
|
|
'<td>' + escHtml(add.reason) + '</td>' +
|
|
'<td><div class="coach-replacements">' +
|
|
(add.synergyWith || []).map(s =>
|
|
'<span class="coach-synergy-chip" data-card-name="' + escHtml(s) + '">' + escHtml(s) + '</span>'
|
|
).join('') +
|
|
'</div></td>';
|
|
tr.querySelector('.coach-card-name').parentElement.dataset.cardName = add.cardName;
|
|
addsBody.appendChild(tr);
|
|
}
|
|
|
|
// Hints
|
|
const hints = session.heuristicHints || [];
|
|
if (hints.length > 0) {
|
|
hintsCard.style.display = 'block';
|
|
hintsList.innerHTML = '';
|
|
for (const hint of hints) {
|
|
const li = document.createElement('li');
|
|
li.textContent = hint;
|
|
hintsList.appendChild(li);
|
|
}
|
|
} else {
|
|
hintsCard.style.display = 'none';
|
|
}
|
|
|
|
// Mana advice
|
|
if (session.manaBaseAdvice) {
|
|
manaCard.style.display = 'block';
|
|
manaText.textContent = session.manaBaseAdvice;
|
|
} else {
|
|
manaCard.style.display = 'none';
|
|
}
|
|
|
|
rawText.textContent = session.rawTextExplanation || session.summary || '(empty)';
|
|
|
|
_attachCardHover(cutsBody);
|
|
_attachCardHover(addsBody);
|
|
updateApplyCount();
|
|
}
|
|
|
|
// ── Apply Suggestions ───────────────────────────────────
|
|
|
|
function updateApplyCount() {
|
|
const cutChecked = cutsBody.querySelectorAll('input[type="checkbox"]:checked');
|
|
const addChecked = addsBody.querySelectorAll('input[type="checkbox"]:checked');
|
|
applyCountEl.textContent = cutChecked.length + ' cuts, ' + addChecked.length + ' adds selected';
|
|
applyBtn.disabled = (cutChecked.length + addChecked.length) === 0 || !applyDeckSelect.value;
|
|
}
|
|
|
|
applyDeckSelect.addEventListener('change', updateApplyCount);
|
|
|
|
async function applySuggestions() {
|
|
const deckId = parseInt(applyDeckSelect.value);
|
|
if (!deckId) { showToast('Select a deck to apply suggestions to', 'warning'); return; }
|
|
|
|
const acceptedCuts = [];
|
|
cutsBody.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
|
|
if (cb.dataset.card) acceptedCuts.push(cb.dataset.card);
|
|
});
|
|
const acceptedAdds = [];
|
|
addsBody.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
|
|
if (cb.dataset.card) acceptedAdds.push(cb.dataset.card);
|
|
});
|
|
|
|
if (acceptedCuts.length === 0 && acceptedAdds.length === 0) {
|
|
showToast('No suggestions selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
applyBtn.disabled = true;
|
|
applyBtn.textContent = 'Applying...';
|
|
|
|
try {
|
|
const res = await fetch(API + '/api/coach/apply', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
session_id: currentSessionId || 'manual',
|
|
deck_id: deckId,
|
|
accepted_cuts: acceptedCuts,
|
|
accepted_adds: acceptedAdds,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: 'Apply failed' }));
|
|
throw new Error(err.detail || 'Apply failed');
|
|
}
|
|
const result = await res.json();
|
|
const msg = 'Applied: ' + result.total_cuts + ' cuts, ' + result.total_adds + ' adds' +
|
|
(result.errors.length > 0 ? ' (' + result.errors.length + ' errors)' : '');
|
|
showToast(msg, result.errors.length > 0 ? 'warning' : 'success');
|
|
} catch (e) {
|
|
showToast('Apply error: ' + e.message, 'error');
|
|
} finally {
|
|
applyBtn.disabled = false;
|
|
applyBtn.textContent = 'Apply Suggestions';
|
|
updateApplyCount();
|
|
}
|
|
}
|
|
|
|
// ── Multi-turn Chat ────────────────────────────────────
|
|
|
|
async function sendChatMessage() {
|
|
const text = chatInput.value.trim();
|
|
if (!text || !deckSelect.value || isStreaming) return;
|
|
|
|
chatHistory.push({ role: 'user', content: text });
|
|
appendChatBubble('user', text);
|
|
chatInput.value = '';
|
|
chatSendBtn.disabled = true;
|
|
|
|
const typingEl = showTypingIndicator();
|
|
isStreaming = true;
|
|
|
|
try {
|
|
const res = await fetch(API + '/api/coach/chat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
deck_id: deckSelect.value,
|
|
messages: chatHistory,
|
|
goals: getGoals(),
|
|
stream: true,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: 'Chat failed' }));
|
|
throw new Error(err.detail || 'Chat failed (' + res.status + ')');
|
|
}
|
|
|
|
typingEl.remove();
|
|
const bubbleEl = appendChatBubble('assistant', '');
|
|
const contentEl = bubbleEl.querySelector('.coach-chat-bubble-content');
|
|
let fullContent = '';
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith('data: ')) continue;
|
|
const payload = trimmed.slice(6);
|
|
if (payload === '[DONE]') break;
|
|
try {
|
|
const chunk = JSON.parse(payload);
|
|
if (chunk.content) {
|
|
fullContent += chunk.content;
|
|
contentEl.innerHTML = formatMarkdown(fullContent);
|
|
scrollChatToBottom();
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
chatHistory.push({ role: 'assistant', content: fullContent });
|
|
contentEl.innerHTML = formatMarkdown(fullContent);
|
|
scrollChatToBottom();
|
|
} catch (e) {
|
|
typingEl.remove();
|
|
appendChatBubble('system', 'Error: ' + e.message);
|
|
} finally {
|
|
isStreaming = false;
|
|
chatSendBtn.disabled = !chatInput.value.trim() || !deckSelect.value;
|
|
}
|
|
}
|
|
|
|
function appendChatBubble(role, content) {
|
|
const welcome = chatMessages.querySelector('.coach-chat-welcome');
|
|
if (welcome) welcome.remove();
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'coach-chat-bubble coach-chat-' + role;
|
|
const label = role === 'user' ? 'You' : role === 'assistant' ? 'Coach' : 'System';
|
|
bubble.innerHTML =
|
|
'<div class="coach-chat-bubble-label">' + label + '</div>' +
|
|
'<div class="coach-chat-bubble-content">' + (content ? formatMarkdown(content) : '') + '</div>';
|
|
chatMessages.appendChild(bubble);
|
|
scrollChatToBottom();
|
|
return bubble;
|
|
}
|
|
|
|
function showTypingIndicator() {
|
|
const el = document.createElement('div');
|
|
el.className = 'coach-chat-typing';
|
|
el.innerHTML =
|
|
'<div class="coach-chat-bubble-label">Coach</div>' +
|
|
'<div class="coach-typing-dots"><span></span><span></span><span></span></div>';
|
|
chatMessages.appendChild(el);
|
|
scrollChatToBottom();
|
|
return el;
|
|
}
|
|
|
|
function scrollChatToBottom() { chatMessages.scrollTop = chatMessages.scrollHeight; }
|
|
|
|
function clearChat() {
|
|
chatHistory = [];
|
|
chatMessages.innerHTML =
|
|
'<div class="coach-chat-welcome">' +
|
|
'<div class="coach-chat-welcome-icon">🤖</div>' +
|
|
'<p>Select a deck above and ask me anything about your strategy, card choices, or upgrades.</p>' +
|
|
'</div>';
|
|
}
|
|
|
|
function formatMarkdown(text) {
|
|
if (!text) return '';
|
|
return escHtml(text)
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
.replace(/\n/g, '<br>');
|
|
}
|
|
|
|
// ── Cards-Like Search ──────────────────────────────────
|
|
|
|
async function searchCardsLike() {
|
|
const card = cardslikeInput.value.trim();
|
|
if (!card) return;
|
|
|
|
const activeColors = [];
|
|
document.querySelectorAll('.coach-cardslike-colors .coach-color-pip.active').forEach(btn => {
|
|
activeColors.push(btn.dataset.color);
|
|
});
|
|
const topN = parseInt(cardslikeTopn.value) || 10;
|
|
|
|
cardslikeResults.innerHTML = '<div class="coach-cardslike-loading"><div class="spinner"></div> Searching embeddings...</div>';
|
|
|
|
try {
|
|
let url = API + '/api/coach/cards-like?card=' + encodeURIComponent(card) + '&top_n=' + topN;
|
|
if (activeColors.length > 0) url += '&colors=' + activeColors.join('');
|
|
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: 'Search failed' }));
|
|
throw new Error(err.detail || 'Search failed');
|
|
}
|
|
|
|
const data = await res.json();
|
|
const results = data.results || [];
|
|
|
|
if (results.length === 0) {
|
|
cardslikeResults.innerHTML = '<div class="coach-cardslike-empty">No similar cards found</div>';
|
|
return;
|
|
}
|
|
|
|
cardslikeResults.innerHTML = '';
|
|
for (const card of results) {
|
|
const div = document.createElement('div');
|
|
div.className = 'coach-cardslike-card';
|
|
const simPct = Math.round(card.similarity * 100);
|
|
const ownedBadge = card.owned_qty > 0
|
|
? '<span class="coach-cl-owned">' + card.owned_qty + ' owned</span>'
|
|
: '<span class="coach-cl-unowned">not owned</span>';
|
|
const price = card.tcg_price ? '$' + Number(card.tcg_price).toFixed(2) : '';
|
|
div.innerHTML =
|
|
'<div class="coach-cl-top">' +
|
|
'<span class="coach-cl-name" data-card-name="' + escHtml(card.name) + '">' + escHtml(card.name) + '</span>' +
|
|
'<span class="coach-cl-sim">' + simPct + '%</span>' +
|
|
'</div>' +
|
|
'<div class="coach-cl-meta">' +
|
|
'<span class="coach-cl-type">' + escHtml(card.types || '') + '</span>' +
|
|
(card.mana_cost ? '<span class="coach-cl-mana">' + escHtml(card.mana_cost) + '</span>' : '') +
|
|
ownedBadge +
|
|
(price ? '<span class="coach-cl-price">' + price + '</span>' : '') +
|
|
'</div>' +
|
|
(card.text ? '<div class="coach-cl-text">' + escHtml(card.text) + '</div>' : '') +
|
|
'<div class="coach-cl-actions">' +
|
|
'<button class="btn btn-ghost btn-xs coach-cl-chat-btn">Add to Chat</button>' +
|
|
'</div>';
|
|
div.querySelector('.coach-cl-chat-btn').addEventListener('click', () => {
|
|
chatInput.value += (chatInput.value ? ' ' : '') + card.name;
|
|
chatInput.focus();
|
|
showToast(card.name + ' added to chat input', 'success');
|
|
});
|
|
cardslikeResults.appendChild(div);
|
|
_attachCardHover(div);
|
|
}
|
|
} catch (e) {
|
|
cardslikeResults.innerHTML = '<div class="coach-cardslike-empty">Error: ' + escHtml(e.message) + '</div>';
|
|
}
|
|
}
|
|
|
|
document.querySelectorAll('.coach-cardslike-colors .coach-color-pip').forEach(btn => {
|
|
btn.addEventListener('click', () => btn.classList.toggle('active'));
|
|
});
|
|
|
|
// ── Toast ─────────────────────────────────────────────
|
|
|
|
function showToast(message, type) {
|
|
const container = document.getElementById('coach-toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = 'coach-toast coach-toast-' + (type || 'info');
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
setTimeout(() => toast.classList.add('coach-toast-show'), 10);
|
|
setTimeout(() => {
|
|
toast.classList.remove('coach-toast-show');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 4000);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────
|
|
|
|
function addMetaChip(label, value) {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'coach-meta-chip';
|
|
chip.innerHTML = '<strong>' + escHtml(label) + ':</strong> ' + escHtml(value);
|
|
metaRow.appendChild(chip);
|
|
}
|
|
|
|
function formatImpact(score) {
|
|
if (score === undefined || score === null)
|
|
return '<span class="coach-impact coach-impact-neutral">\u2014</span>';
|
|
const fixed = score.toFixed(3);
|
|
if (score < -0.01) return '<span class="coach-impact coach-impact-negative">' + fixed + '</span>';
|
|
if (score > 0.01) return '<span class="coach-impact coach-impact-positive">+' + fixed + '</span>';
|
|
return '<span class="coach-impact coach-impact-neutral">' + fixed + '</span>';
|
|
}
|
|
|
|
function formatTime(isoStr) {
|
|
try {
|
|
const d = new Date(isoStr);
|
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
} catch { return isoStr; }
|
|
}
|
|
|
|
function escHtml(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
})();
|