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:
1 parent
409aba26f4
commit
9eaa41a4ae
23 files changed
+376
-152
No files matched your search
+30
-14
@@ -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
@@ -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">↻ Regenerate</button>
|
||||
<button class="dg-btn dg-btn-primary" id="dg-save-btn">💾 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">🛒 Shopping</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dg-card-groups" class="dg-card-groups"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
+6
-3
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user