feat: deck dropdown, user-decks pipeline, deckgen fixes, gpt-oss removal

- Add user-decks/ directory and USER_DECKS_DIR config for exported decks
- Update export-to-sim to write to user-decks/ with static mount in lab_api
- Replace import/paste UI with API-backed "My Decks" dropdown in LAN client
  and AI Lab, fetching from GET /api/lab/decks with source grouping
- Fix GET /api/lab/decks to JOIN deck_cards for totalCards count
- Fix deckgenerator: add missing DOM elements (dg-strategy-bar, dg-stats-bar,
  dg-card-groups) inside #dg-results, null guards on render functions,
  fix setLoading always hiding results
- Replace gpt-oss:20b with claude-opus-4-20250514 across all files
- Add user-decks/ to _BUILTIN_DECK_DIRS in deck_service.py
- Enrich missing type_line from Forge card cache in DB deck path
- Collapse import bar behind <details> toggle in AI Lab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rgriffithandClaude Opus 4.6 committed 2026-04-05 01:17:30 -04:00
1 parent 409aba26f4
commit 9eaa41a4ae
23 files changed
+376 -152

No files matched your search

+30 -14
View File
@@ -93,24 +93,24 @@ const AiLab = (() => {
</div>
<p class="lab-subtitle">Headless 3-AI batch simulation — stress-test your decks at scale</p>
<!-- v3: Import Bar -->
<div class="lab-import-bar">
<div class="lab-import-bar-label">Import Deck</div>
<!-- v3: Import Bar (collapsed by default) -->
<details class="lab-import-bar">
<summary class="lab-import-bar-label" style="cursor:pointer;">\u2B07 Advanced Import (URL / Text / Precon)</summary>
<div class="lab-import-actions">
<button class="lab-import-action-btn" onclick="AiLab.showUrlImport()" title="Import from Archidekt or EDHREC URL">
<span class="import-icon">🔗</span> URL Import
<span class="import-icon">\uD83D\uDD17</span> URL Import
</button>
<button class="lab-import-action-btn" onclick="AiLab.showMetaPicker()" title="Browse EDHREC average decks for popular commanders">
<span class="import-icon">📊</span> Commander Meta
<span class="import-icon">\uD83D\uDCCA</span> Commander Meta
</button>
<button class="lab-import-action-btn" onclick="AiLab.showTextImport()" title="Paste a card list directly">
<span class="import-icon">📋</span> Text Import
<span class="import-icon">\uD83D\uDCCB</span> Text Import
</button>
<button class="lab-import-action-btn lab-precon-btn" onclick="AiLab.showPreconPicker()" title="Browse and load official Commander precon decks">
<span class="import-icon">🎴</span> Precon Decks
<span class="import-icon">\uD83C\uDCB4</span> Precon Decks
</button>
</div>
</div>
</details>
<!-- v3: URL Import Panel (hidden by default) -->
<div class="lab-url-import" id="lab-url-import">
@@ -403,7 +403,9 @@ const AiLab = (() => {
const data = await res.json();
decks = (data.decks || []).map(d => ({
name: d.name,
commander: d.commander || d.name.replace(/_/g, ' '),
commander: d.commander || d.commander_name || d.name.replace(/_/g, ' '),
source: d.source || 'forge',
deck_id: d.deck_id || null,
}));
}
} catch (err) {
@@ -427,9 +429,10 @@ const AiLab = (() => {
const prev = select.value;
select.innerHTML = '<option value="">— Select Deck —</option>';
// Group: imported decks first
const imported = availableDecks.filter(d => importedDeckMeta[d.name]);
const local = availableDecks.filter(d => !importedDeckMeta[d.name]);
// Split into three groups
const imported = availableDecks.filter(d => importedDeckMeta[d.name]);
const myDecks = availableDecks.filter(d => d.source === 'deckbuilder' && !importedDeckMeta[d.name]);
const forgeDecks = availableDecks.filter(d => d.source !== 'deckbuilder' && !importedDeckMeta[d.name]);
if (imported.length > 0) {
const group = document.createElement('optgroup');
@@ -446,10 +449,23 @@ const AiLab = (() => {
select.appendChild(group);
}
if (local.length > 0) {
if (myDecks.length > 0) {
const group = document.createElement('optgroup');
group.label = '\uD83D\uDDC2 My Decks (Deck Builder)';
myDecks.forEach(deck => {
const opt = document.createElement('option');
opt.value = deck.name;
opt.textContent = deck.name + (deck.commander ? ` \u2014 ${deck.commander}` : '');
opt.dataset.commander = deck.commander || '';
group.appendChild(opt);
});
select.appendChild(group);
}
if (forgeDecks.length > 0) {
const group = document.createElement('optgroup');
group.label = 'Local Decks';
local.forEach(deck => {
forgeDecks.forEach(deck => {
const opt = document.createElement('option');
opt.value = deck.name;
opt.textContent = deck.name;
+21 -7
View File
@@ -142,11 +142,10 @@
<div class="dg-model-row">
<label class="dg-label">
AI Model
<span class="tip-icon" data-tip="The local LLM used to generate the deck list. Larger models produce better card selections but are slower. Requires Ollama running locally with the selected model pulled.">?</span>
<span class="tip-icon" data-tip="Deck generation is powered by Claude Opus 4, Anthropic's most capable model.">?</span>
</label>
<select class="dg-select" id="dg-model">
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
</select>
<input type="hidden" id="dg-model" value="claude-opus-4-20250514" />
<span style="font-size:0.8rem; color:var(--text-muted, #888);">claude-opus-4-20250514</span>
</div>
</div>
</div>
@@ -196,7 +195,7 @@
<!-- Loading State -->
<div class="dg-loading" id="dg-loading" style="display:none">
<div class="dg-spinner"></div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Claude Opus 4...</div>
</div>
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
<div class="dg-progress-bar">
@@ -208,8 +207,23 @@
</div>
</div>
<!-- Results placeholder — rest generated dynamically by deckgenerator.js -->
<div class="dg-results" id="dg-results" style="display:none"></div>
<!-- Results -->
<div class="dg-results" id="dg-results" style="display:none">
<div class="dg-results-header">
<div id="dg-strategy-bar"></div>
<div id="dg-stats-bar" class="dg-stats-bar"></div>
<div id="dg-sub-summary" style="display:none"></div>
<div class="dg-result-actions">
<button class="dg-btn dg-btn-secondary" id="dg-regenerate-btn">&#8635; Regenerate</button>
<button class="dg-btn dg-btn-primary" id="dg-save-btn">&#128190; Save to Deck Builder</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-csv">CSV</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-dck">Forge .dck</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-moxfield">Moxfield</button>
<button class="dg-btn dg-btn-ghost" id="dg-export-shopping">&#128722; Shopping</button>
</div>
</div>
<div id="dg-card-groups" class="dg-card-groups"></div>
</div>
</section>
</main>
+6 -3
View File
@@ -159,7 +159,7 @@ const DeckGenerator = (() => {
if (!state.v3Ready) {
// Warn but do not block — local/Ollama mode may still work
console.warn('V3 status: not initialized. Error:', data.error || 'unknown');
toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', 'warning');
toast('V3 generator not ready — check that the Anthropic API key is configured', 'warning');
}
} catch (e) {
console.warn('V3 status check failed:', e);
@@ -386,7 +386,7 @@ const DeckGenerator = (() => {
// ── Generate Deck ───────────────────────────────────────
async function generateDeck() {
clearLog();
logMessage('Connecting to local AI model (gpt-oss:20b)...', 'info');
logMessage('Connecting to Claude AI...', 'info');
if (!state.commander) { toast('Select a commander first', 'error'); return; }
// Non-blocking warning only — local Ollama mode does not require v3Ready
if (!state.v3Ready) {
@@ -435,7 +435,7 @@ const DeckGenerator = (() => {
state.isLoading = loading;
$('dg-empty-state').style.display = 'none';
$('dg-loading').style.display = loading ? 'flex' : 'none';
$('dg-results').style.display = loading ? 'none' : 'none';
$('dg-results').style.display = loading ? 'none' : 'block';
$('dg-preview-btn').disabled = loading || !state.commander;
if (text) $('dg-loading-text').textContent = text;
}
@@ -454,6 +454,7 @@ const DeckGenerator = (() => {
function renderStrategyBar(result) {
const bar = $('dg-strategy-bar');
if (!bar) return;
const bracket = result.bracket || {};
const archetype = result.archetype || '';
const strategy = result.strategy_summary || '';
@@ -472,6 +473,7 @@ const DeckGenerator = (() => {
function renderStatsBar(result) {
const stats = result.stats || {};
const container = $('dg-stats-bar');
if (!container) return;
const chips = [
chipHtml('Total', stats.total_cards || 0, ''),
@@ -507,6 +509,7 @@ const DeckGenerator = (() => {
function renderCardGroups(result) {
const container = $('dg-card-groups');
if (!container) return;
const cards = result.cards || [];
const commander = result.commander;
+58 -15
View File
@@ -39,16 +39,41 @@
// ── Init: load decks ──
async function loadDecks() {
try {
const res = await fetch('/api/decks');
const res = await fetch('/api/lab/decks');
if (!res.ok) return;
const data = await res.json();
const decks = data.decks || data || [];
decks.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name + (d.commander ? ' (' + d.commander + ')' : '');
deckSelect.appendChild(opt);
});
const decks = data.decks || [];
// Split into groups by source
const dbDecks = decks.filter(d => d.source === 'deckbuilder');
const forgeDecks = decks.filter(d => d.source === 'forge');
// My Decks — from Deck Builder (stored in SQLite)
if (dbDecks.length > 0) {
const group = document.createElement('optgroup');
group.label = '\u{1F0CF} My Decks (Deck Builder)';
dbDecks.forEach(d => {
const opt = document.createElement('option');
opt.value = 'db:' + d.deck_id;
opt.textContent = d.name + (d.commander ? ' (' + d.commander + ')' : '');
group.appendChild(opt);
});
deckSelect.appendChild(group);
}
// Forge .dck files (user-decks/, precon-decks/, sample-decks/)
if (forgeDecks.length > 0) {
const group = document.createElement('optgroup');
group.label = '\u{1F4C1} Deck Files (.dck)';
forgeDecks.forEach(d => {
const opt = document.createElement('option');
opt.value = d.name;
opt.textContent = d.name.replace(/_/g, ' ');
group.appendChild(opt);
});
deckSelect.appendChild(group);
}
} catch (e) {
console.warn('Could not load decks:', e);
}
@@ -165,18 +190,17 @@
// ── Run from deck ID ──
runBtn.addEventListener('click', async () => {
const deckId = parseInt(deckSelect.value);
if (!deckId) return;
const val = deckSelect.value;
if (!val) return;
const numGames = parseInt(numGamesInput.value) || 10;
const numGames = parseInt(numGamesInput.value) || 10;
const recordLogs = recordLogsChk.checked;
const useDeepSeek = oppSelect.value === 'deepseek';
// If DeepSeek selected but not connected, try to connect first
if (useDeepSeek && !dsConnected) {
await connectDeepSeek();
if (!dsConnected) {
alert('Could not connect to DeepSeek. Make sure LM Studio is running at ' + dsApiBase.value);
alert('Could not connect to DeepSeek. Make sure Ollama is running at ' + dsApiBase.value);
return;
}
await applyDeepSeekConfig();
@@ -189,16 +213,35 @@
hideResults();
try {
const endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck';
let endpoint, body;
if (val.startsWith('db:')) {
// Deck Builder deck — auto-export to user-decks/ first
const deckId = parseInt(val.replace('db:', ''));
const exportRes = await fetch(`/api/decks/${deckId}/export-to-sim`, { method: 'POST' });
const exportData = await exportRes.json();
if (!exportData.success) throw new Error('Export failed: ' + (exportData.detail || 'unknown'));
endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck';
body = { deckId, numGames, recordLogs };
} else {
// File-based deck (already a .dck)
endpoint = useDeepSeek ? '/api/sim/run-deepseek' : '/api/sim/run-from-deck';
body = { deckName: val, numGames, recordLogs };
}
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deckId, numGames, recordLogs }),
body: JSON.stringify(body),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
currentSimId = data.simId;
startPolling();
} catch (e) {
alert('Error: ' + e.message);
resetRunBtn();