(() => { const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'opencode', 'cohere']; const PLANNING_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'cohere']; const pageType = document?.body?.dataset?.page || 'build'; console.log('Admin JS loaded, pageType:', pageType); const state = { available: [], configured: [], opencodeModels: [], // Models from OpenCode (order determines fallback chain for execution) publicModels: [], // Public-facing models (displayed to users in builder dropdown) icons: [], accounts: [], affiliates: [], withdrawals: [], planSettings: { provider: 'openrouter', planningChain: [] }, providerLimits: {}, providerUsage: [], providerOptions: [], providerModels: {}, tokenRates: {}, }; const el = { // OpenCode Models (fallback chain) opencodeModelForm: document.getElementById('opencode-model-form'), opencodeModelSelect: document.getElementById('opencode-model-select'), opencodeModelLabel: document.getElementById('opencode-model-label'), opencodeModelTier: document.getElementById('opencode-model-tier'), opencodeModelIcon: document.getElementById('opencode-model-icon'), opencodeModelMedia: document.getElementById('opencode-model-media'), opencodeModelStatus: document.getElementById('opencode-model-status'), reloadOpencodeModels: document.getElementById('reload-opencode-models'), opencodeModelsList: document.getElementById('opencode-models-list'), opencodeModelsCount: document.getElementById('opencode-models-count'), // Public Models (user-facing selection) publicModelForm: document.getElementById('public-model-form'), publicModelLabel: document.getElementById('public-model-label'), publicModelTier: document.getElementById('public-model-tier'), publicModelIcon: document.getElementById('public-model-icon'), publicModelMedia: document.getElementById('public-model-media'), publicModelStatus: document.getElementById('public-model-status'), publicModelsList: document.getElementById('public-models-list'), publicModelsCount: document.getElementById('public-models-count'), // Provider chain (legacy - kept for compatibility) providerChainForm: document.getElementById('provider-chain-form'), chainProvider: document.getElementById('chain-provider'), chainModel: document.getElementById('chain-model'), providerChainList: document.getElementById('provider-chain-list'), providerChainCount: document.getElementById('provider-chain-count'), // Legacy elements (keep for compatibility) availableModels: document.getElementById('available-models'), displayLabel: document.getElementById('display-label'), modelTier: document.getElementById('model-tier'), iconSelect: document.getElementById('icon-select'), iconList: document.getElementById('icon-list'), modelForm: document.getElementById('model-form'), status: document.getElementById('admin-status'), configuredList: document.getElementById('configured-list'), configuredCount: document.getElementById('configured-count'), logout: document.getElementById('admin-logout'), refresh: document.getElementById('admin-refresh'), reloadAvailable: document.getElementById('reload-available'), // legacy planning fields (build page no longer renders these) orForm: document.getElementById('openrouter-form'), orPrimary: document.getElementById('or-primary'), orBackup1: document.getElementById('or-backup1'), orBackup2: document.getElementById('or-backup2'), orBackup3: document.getElementById('or-backup3'), orStatus: document.getElementById('or-status'), planProviderForm: document.getElementById('plan-provider-form'), planProvider: document.getElementById('plan-provider'), planProviderStatus: document.getElementById('plan-provider-status'), planPriorityList: document.getElementById('plan-priority-list'), addPlanRow: document.getElementById('add-plan-row'), planChainStatus: document.getElementById('plan-chain-status'), mistralForm: document.getElementById('mistral-form'), mistralPrimary: document.getElementById('mistral-primary'), mistralBackup1: document.getElementById('mistral-backup1'), mistralBackup2: document.getElementById('mistral-backup2'), mistralBackup3: document.getElementById('mistral-backup3'), mistralStatus: document.getElementById('mistral-status'), accountsTable: document.getElementById('accounts-table'), accountsCount: document.getElementById('accounts-count'), affiliatesTable: document.getElementById('affiliates-table'), affiliatesCount: document.getElementById('affiliates-count'), withdrawalsTable: document.getElementById('withdrawals-table'), withdrawalsCount: document.getElementById('withdrawals-count'), providerOrder: document.getElementById('provider-order'), providerLimitForm: document.getElementById('provider-limit-form'), limitProvider: document.getElementById('limit-provider'), limitScope: document.getElementById('limit-scope'), limitModel: document.getElementById('limit-model'), limitModelInput: document.getElementById('limit-model-input'), limitTpm: document.getElementById('limit-tpm'), limitTph: document.getElementById('limit-tph'), limitTpd: document.getElementById('limit-tpd'), limitRpm: document.getElementById('limit-rpm'), limitRph: document.getElementById('limit-rph'), limitRpd: document.getElementById('limit-rpd'), limitBackup: document.getElementById('limit-backup'), providerLimitStatus: document.getElementById('provider-limit-status'), providerUsage: document.getElementById('provider-usage'), availableModelDatalist: document.getElementById('available-model-datalist'), supportsMedia: document.getElementById('supports-media'), // Plan tokens UI planTokensTable: document.getElementById('plan-tokens-table'), savePlanTokens: document.getElementById('save-plan-tokens'), planTokensStatus: document.getElementById('plan-tokens-status'), tokenRateUsd: document.getElementById('token-rate-usd'), tokenRateGbp: document.getElementById('token-rate-gbp'), tokenRateEur: document.getElementById('token-rate-eur'), saveTokenRates: document.getElementById('save-token-rates'), tokenRatesStatus: document.getElementById('token-rates-status'), // Cancel messages UI cancelAllMessages: document.getElementById('cancel-all-messages'), cancelMessagesStatus: document.getElementById('cancel-messages-status'), externalTestingRun: document.getElementById('external-testing-run'), externalTestingStatus: document.getElementById('external-testing-status'), externalTestingOutput: document.getElementById('external-testing-output'), externalTestingConfig: document.getElementById('external-testing-config'), ollamaTestRun: document.getElementById('ollama-test-run'), ollamaTestStatus: document.getElementById('ollama-test-status'), ollamaTestOutput: document.getElementById('ollama-test-output'), }; function ensureAvailableDatalist() { if (el.availableModelDatalist) return el.availableModelDatalist; const dl = document.createElement('datalist'); dl.id = 'available-model-datalist'; document.body.appendChild(dl); el.availableModelDatalist = dl; return dl; } function getAvailableModelNames() { const names = new Set(); (state.available || []).forEach((m) => { const name = m.name || m.id || m; if (name) names.add(name); }); (state.configured || []).forEach((m) => { if (m.name) names.add(m.name); }); // include provider-specific models discovered by provider limits endpoint Object.values(state.providerModels || {}).forEach((arr) => { (arr || []).forEach((name) => { if (name) names.add(name); }); }); return Array.from(names); } function syncAvailableModelDatalist() { const dl = ensureAvailableDatalist(); if (!dl) return; dl.innerHTML = ''; getAvailableModelNames().forEach((name) => { const opt = document.createElement('option'); opt.value = name; dl.appendChild(opt); }); } function setStatus(msg, isError = false) { if (!el.status) return; el.status.textContent = msg || ''; el.status.style.color = isError ? 'var(--danger)' : 'inherit'; } function setOrStatus(msg, isError = false) { if (!el.orStatus) return; el.orStatus.textContent = msg || ''; el.orStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setMistralStatus(msg, isError = false) { if (!el.mistralStatus) return; el.mistralStatus.textContent = msg || ''; el.mistralStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setPlanProviderStatus(msg, isError = false) { if (!el.planProviderStatus) return; el.planProviderStatus.textContent = msg || ''; el.planProviderStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setPlanChainStatus(msg, isError = false) { if (!el.planChainStatus) return; el.planChainStatus.textContent = msg || ''; el.planChainStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setProviderLimitStatus(msg, isError = false) { if (!el.providerLimitStatus) return; el.providerLimitStatus.textContent = msg || ''; el.providerLimitStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setPublicModelStatus(msg, isError = false) { if (!el.publicModelStatus) return; el.publicModelStatus.textContent = msg || ''; el.publicModelStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setOpencodeModelStatus(msg, isError = false) { if (!el.opencodeModelStatus) return; el.opencodeModelStatus.textContent = msg || ''; el.opencodeModelStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setProviderChainStatus(msg, isError = false) { if (!el.providerChainStatus) return; el.providerChainStatus.textContent = msg || ''; el.providerChainStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function setExternalTestingStatus(msg, isError = false) { if (!el.externalTestingStatus) return; el.externalTestingStatus.textContent = msg || ''; el.externalTestingStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function renderExternalTestingConfig(config) { if (!el.externalTestingConfig) return; el.externalTestingConfig.innerHTML = ''; if (!config) return; const rows = [ ['WP host', config.wpHost || '—'], ['WP path', config.wpPath || '—'], ['Base URL', config.wpBaseUrl || '—'], ['Multisite enabled', config.enableMultisite ? 'Yes' : 'No'], ['Subsite mode', config.subsiteMode || '—'], ['Subsite domain', config.subsiteDomain || '—'], ['Max concurrent tests', String(config.maxConcurrentTests ?? '—')], ['Auto cleanup', config.autoCleanup ? 'Yes' : 'No'], ['Cleanup delay (ms)', String(config.cleanupDelayMs ?? '—')], ['SSH key configured', config.sshKeyConfigured ? 'Yes' : 'No'], ]; rows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '180px'; const strong = document.createElement('strong'); strong.textContent = label; labelWrap.appendChild(strong); const valueWrap = document.createElement('div'); valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); el.externalTestingConfig.appendChild(row); }); } function renderExternalTestingOutput(result) { if (!el.externalTestingOutput) return; el.externalTestingOutput.innerHTML = ''; if (!result) return; const summary = document.createElement('div'); summary.className = 'admin-row'; const summaryLabel = document.createElement('div'); summaryLabel.style.minWidth = '180px'; const summaryStrong = document.createElement('strong'); summaryStrong.textContent = 'Overall result'; summaryLabel.appendChild(summaryStrong); const summaryValue = document.createElement('div'); summaryValue.textContent = result.ok ? 'Passed' : 'Failed'; summary.appendChild(summaryLabel); summary.appendChild(summaryValue); el.externalTestingOutput.appendChild(summary); const detailRows = [ ['Subsite URL', result.subsite_url || '—'], ['Duration', typeof result.duration === 'number' ? `${(result.duration / 1000).toFixed(1)}s` : '—'], ]; detailRows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '180px'; const strong = document.createElement('strong'); strong.textContent = label; labelWrap.appendChild(strong); const valueWrap = document.createElement('div'); valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); el.externalTestingOutput.appendChild(row); }); const scenarioResults = result?.test_results?.cli_tests?.results || []; if (scenarioResults.length) { scenarioResults.forEach((scenario) => { const row = document.createElement('div'); row.className = 'admin-row'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '180px'; const strong = document.createElement('strong'); strong.textContent = scenario.name || 'Scenario'; labelWrap.appendChild(strong); const valueWrap = document.createElement('div'); valueWrap.textContent = scenario.status === 'passed' ? 'Passed' : 'Failed'; if (scenario.status !== 'passed') { valueWrap.style.color = 'var(--danger)'; } row.appendChild(labelWrap); row.appendChild(valueWrap); el.externalTestingOutput.appendChild(row); }); } const errors = Array.isArray(result.errors) ? result.errors : []; if (errors.length) { errors.forEach((err) => { const row = document.createElement('div'); row.className = 'admin-row'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '180px'; const strong = document.createElement('strong'); strong.textContent = 'Error'; labelWrap.appendChild(strong); const valueWrap = document.createElement('div'); valueWrap.textContent = err; valueWrap.style.color = 'var(--danger)'; row.appendChild(labelWrap); row.appendChild(valueWrap); el.externalTestingOutput.appendChild(row); }); } } async function loadExternalTestingStatus() { const data = await api('/api/admin/external-testing-status'); renderExternalTestingConfig(data.config || {}); } // --- Ollama Test UI --- function setOllamaTestStatus(msg, isError = false) { if (!el.ollamaTestStatus) return; el.ollamaTestStatus.textContent = msg || ''; el.ollamaTestStatus.style.color = isError ? 'var(--danger)' : 'inherit'; } function renderOllamaTestOutput(data) { if (!el.ollamaTestOutput) return; el.ollamaTestOutput.innerHTML = ''; if (!data) return; // Config section const configSection = document.createElement('div'); configSection.style.marginBottom = '16px'; configSection.style.padding = '12px'; configSection.style.background = 'var(--surface)'; configSection.style.borderRadius = '6px'; const configTitle = document.createElement('div'); configTitle.style.fontWeight = '600'; configTitle.style.marginBottom = '8px'; configTitle.textContent = 'Configuration'; configSection.appendChild(configTitle); const configRows = [ ['URL', data.config?.url || '—'], ['Model', data.config?.model || '—'], ['API Key Configured', data.config?.apiKeyConfigured ? 'Yes' : 'No'], ['API Key Preview', data.config?.apiKeyPreview || '—'], ]; configRows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; row.style.marginBottom = '4px'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '140px'; labelWrap.style.fontSize = '12px'; labelWrap.style.color = 'var(--muted)'; labelWrap.textContent = label; const valueWrap = document.createElement('div'); valueWrap.style.fontSize = '12px'; valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); configSection.appendChild(row); }); el.ollamaTestOutput.appendChild(configSection); // Result section if (data.result) { const resultSection = document.createElement('div'); resultSection.style.marginBottom = '16px'; resultSection.style.padding = '12px'; resultSection.style.background = 'rgba(0, 200, 0, 0.1)'; resultSection.style.borderRadius = '6px'; resultSection.style.border = '1px solid var(--shopify-green)'; const resultTitle = document.createElement('div'); resultTitle.style.fontWeight = '600'; resultTitle.style.marginBottom = '8px'; resultTitle.style.color = 'var(--shopify-green)'; resultTitle.textContent = `✓ Test Passed (${data.duration}ms)`; resultSection.appendChild(resultTitle); const resultRows = [ ['Response', data.result.reply || '—'], ['Model Used', data.result.model || '—'], ]; resultRows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; row.style.marginBottom = '4px'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '140px'; labelWrap.style.fontSize = '12px'; labelWrap.style.color = 'var(--muted)'; labelWrap.textContent = label; const valueWrap = document.createElement('div'); valueWrap.style.fontSize = '12px'; valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); resultSection.appendChild(row); }); el.ollamaTestOutput.appendChild(resultSection); } // Error section if (data.error) { const errorSection = document.createElement('div'); errorSection.style.marginBottom = '16px'; errorSection.style.padding = '12px'; errorSection.style.background = 'rgba(255, 0, 0, 0.05)'; errorSection.style.borderRadius = '6px'; errorSection.style.border = '1px solid var(--danger)'; const errorTitle = document.createElement('div'); errorTitle.style.fontWeight = '600'; errorTitle.style.marginBottom = '8px'; errorTitle.style.color = 'var(--danger)'; errorTitle.textContent = `✗ Test Failed (${data.duration}ms)`; errorSection.appendChild(errorTitle); const errorRows = [ ['Error Message', data.error.message || '—'], ['Status Code', data.error.status || '—'], ['Detail', data.error.detail || '—'], ['Auth Error', data.error.isAuthError ? 'Yes' : 'No'], ['Model Missing', data.error.isModelMissing ? 'Yes' : 'No'], ['Error Code', data.error.code || '—'], ]; errorRows.forEach(([label, value]) => { const row = document.createElement('div'); row.className = 'admin-row'; row.style.marginBottom = '4px'; const labelWrap = document.createElement('div'); labelWrap.style.minWidth = '140px'; labelWrap.style.fontSize = '12px'; labelWrap.style.color = 'var(--muted)'; labelWrap.textContent = label; const valueWrap = document.createElement('div'); valueWrap.style.fontSize = '12px'; valueWrap.style.color = label === 'Error Message' ? 'var(--danger)' : 'inherit'; valueWrap.textContent = value; row.appendChild(labelWrap); row.appendChild(valueWrap); errorSection.appendChild(row); }); el.ollamaTestOutput.appendChild(errorSection); } } async function api(path, options = {}) { const res = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }); const text = await res.text(); const data = text ? JSON.parse(text) : {}; if (res.status === 401) { window.location.href = '/admin/login'; throw new Error('Unauthorized'); } if (!res.ok) throw new Error(data.error || res.statusText); return data; } function parseProviderOrderInput(raw, fallbackModel) { const input = (raw || '').trim(); if (!input) return []; const parts = input.split(',').map((p) => p.trim()).filter(Boolean); return parts.map((part, idx) => { const segments = part.split(':').map((s) => s.trim()); const provider = segments[0]; const model = segments[1] || fallbackModel || provider; if (!provider) return null; return { provider, model, primary: idx === 0 }; }).filter(Boolean).map((p, idx) => ({ ...p, primary: idx === 0 })); } function renderAvailable() { if (!el.availableModels) return; el.availableModels.innerHTML = ''; if (!state.available.length) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = 'No models discovered'; opt.disabled = true; opt.selected = true; el.availableModels.appendChild(opt); return; } state.available.forEach((m) => { const opt = document.createElement('option'); opt.value = m.name || m.id || m; opt.textContent = m.label || m.name || m.id || m; el.availableModels.appendChild(opt); }); if (!el.displayLabel.value) { const first = state.available[0]; if (first) el.displayLabel.value = first.label || first.name || ''; } } function renderIcons() { // Populate legacy icon select if (el.iconSelect) { el.iconSelect.innerHTML = ''; const none = document.createElement('option'); none.value = ''; none.textContent = 'No icon'; el.iconSelect.appendChild(none); state.icons.forEach((iconPath) => { const opt = document.createElement('option'); opt.value = iconPath; opt.textContent = iconPath.replace('/assets/', ''); el.iconSelect.appendChild(opt); }); } // Populate OpenCode model icon select if (el.opencodeModelIcon) { el.opencodeModelIcon.innerHTML = ''; const none = document.createElement('option'); none.value = ''; none.textContent = 'No icon'; el.opencodeModelIcon.appendChild(none); state.icons.forEach((iconPath) => { const opt = document.createElement('option'); opt.value = iconPath; opt.textContent = iconPath.replace('/assets/', ''); el.opencodeModelIcon.appendChild(opt); }); } // Populate public model icon select if (el.publicModelIcon) { el.publicModelIcon.innerHTML = ''; const none = document.createElement('option'); none.value = ''; none.textContent = 'No icon'; el.publicModelIcon.appendChild(none); state.icons.forEach((iconPath) => { const opt = document.createElement('option'); opt.value = iconPath; opt.textContent = iconPath.replace('/assets/', ''); el.publicModelIcon.appendChild(opt); }); } if (el.iconList) { el.iconList.innerHTML = ''; if (!state.icons.length) { const div = document.createElement('div'); div.className = 'muted'; div.textContent = 'Add icons to /chat/public/assets to see them here.'; el.iconList.appendChild(div); return; } state.icons.forEach((iconPath) => { const row = document.createElement('div'); row.className = 'admin-row'; const chip = document.createElement('div'); chip.className = 'model-chip'; const img = document.createElement('img'); img.src = iconPath; img.alt = ''; chip.appendChild(img); const span = document.createElement('span'); span.textContent = iconPath.replace('/assets/', ''); chip.appendChild(span); row.appendChild(chip); el.iconList.appendChild(row); }); } } // Render OpenCode models list with up/down ordering function renderOpencodeModels() { if (!el.opencodeModelsList) return; el.opencodeModelsList.innerHTML = ''; if (el.opencodeModelsCount) el.opencodeModelsCount.textContent = state.opencodeModels.length.toString(); if (!state.opencodeModels.length) { const empty = document.createElement('div'); empty.className = 'muted'; empty.textContent = 'No OpenCode models configured. Add models to enable fallback chain for build execution.'; el.opencodeModelsList.appendChild(empty); return; } state.opencodeModels.forEach((m, idx) => { const row = document.createElement('div'); row.className = 'provider-row slim'; const header = document.createElement('div'); header.className = 'provider-row-header'; const info = document.createElement('div'); info.className = 'model-chip'; // Priority badge const order = document.createElement('span'); order.className = 'pill'; order.textContent = idx === 0 ? 'Primary' : `#${idx + 1}`; if (idx === 0) order.style.background = 'var(--shopify-green)'; info.appendChild(order); if (m.icon) { const img = document.createElement('img'); img.src = m.icon; img.alt = ''; info.appendChild(img); } const label = document.createElement('span'); label.textContent = m.label || m.name; info.appendChild(label); const namePill = document.createElement('span'); namePill.className = 'pill'; namePill.textContent = m.name; info.appendChild(namePill); const tierMeta = document.createElement('span'); tierMeta.className = 'pill'; const tierName = (m.tier || 'free').toUpperCase(); const multiplier = m.tier === 'pro' ? 3 : (m.tier === 'plus' ? 2 : 1); tierMeta.textContent = `${tierName} (${multiplier}x)`; info.appendChild(tierMeta); if (m.supportsMedia) { const mediaBadge = document.createElement('span'); mediaBadge.className = 'pill'; mediaBadge.style.background = 'var(--shopify-green)'; mediaBadge.textContent = 'Media'; info.appendChild(mediaBadge); } header.appendChild(info); const headerActions = document.createElement('div'); headerActions.className = 'provider-row-actions'; // Up button const upBtn = document.createElement('button'); upBtn.className = 'ghost'; upBtn.textContent = '↑'; upBtn.title = 'Move up'; upBtn.disabled = idx === 0; upBtn.addEventListener('click', async () => { const next = [...state.opencodeModels]; const [item] = next.splice(idx, 1); next.splice(Math.max(0, idx - 1), 0, item); await persistOpencodeModelsOrder(next); }); headerActions.appendChild(upBtn); // Down button const downBtn = document.createElement('button'); downBtn.className = 'ghost'; downBtn.textContent = '↓'; downBtn.title = 'Move down'; downBtn.disabled = idx === state.opencodeModels.length - 1; downBtn.addEventListener('click', async () => { const next = [...state.opencodeModels]; const [item] = next.splice(idx, 1); next.splice(Math.min(state.opencodeModels.length, idx + 1), 0, item); await persistOpencodeModelsOrder(next); }); headerActions.appendChild(downBtn); const delBtn = document.createElement('button'); delBtn.className = 'ghost'; delBtn.textContent = 'Delete'; delBtn.addEventListener('click', async () => { delBtn.disabled = true; try { await api(`/api/admin/models/${m.id}?type=opencode`, { method: 'DELETE' }); await loadConfigured(); } catch (err) { setStatus(err.message, true); } delBtn.disabled = false; }); headerActions.appendChild(delBtn); // Inline icon editor button const editIconBtn = document.createElement('button'); editIconBtn.className = 'ghost'; editIconBtn.textContent = 'Edit icon'; editIconBtn.addEventListener('click', () => { let editor = header.querySelector('.icon-editor'); if (editor) return editor.remove(); editor = document.createElement('div'); editor.className = 'icon-editor'; const sel = document.createElement('select'); const none = document.createElement('option'); none.value = ''; none.textContent = 'No icon'; sel.appendChild(none); (state.icons || []).forEach((iconPath) => { const o = document.createElement('option'); o.value = iconPath; o.textContent = iconPath.replace('/assets/', ''); sel.appendChild(o); }); sel.value = m.icon || ''; editor.appendChild(sel); const saveBtn = document.createElement('button'); saveBtn.className = 'primary'; saveBtn.textContent = 'Save'; saveBtn.addEventListener('click', async () => { saveBtn.disabled = true; try { await persistOpencodeModelChanges(m.id, { icon: sel.value }); } catch (err) { setStatus(err.message, true); } saveBtn.disabled = false; }); editor.appendChild(saveBtn); const cancelBtn = document.createElement('button'); cancelBtn.className = 'ghost'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => editor.remove()); editor.appendChild(cancelBtn); headerActions.appendChild(editor); }); headerActions.appendChild(editIconBtn); // Supports media checkbox const mediaToggle = document.createElement('label'); mediaToggle.style.display = 'flex'; mediaToggle.style.alignItems = 'center'; mediaToggle.style.gap = '6px'; mediaToggle.style.marginLeft = '8px'; const mediaCheckbox = document.createElement('input'); mediaCheckbox.type = 'checkbox'; mediaCheckbox.checked = m.supportsMedia ?? false; mediaCheckbox.addEventListener('change', async () => { mediaCheckbox.disabled = true; try { await persistOpencodeModelChanges(m.id, { supportsMedia: mediaCheckbox.checked }); } catch (err) { setStatus(err.message, true); } mediaCheckbox.disabled = false; }); mediaToggle.appendChild(mediaCheckbox); const mediaLabel = document.createElement('span'); mediaLabel.textContent = 'Media'; mediaLabel.style.fontSize = '12px'; mediaLabel.style.color = 'var(--muted)'; mediaToggle.appendChild(mediaLabel); headerActions.appendChild(mediaToggle); header.appendChild(headerActions); row.appendChild(header); el.opencodeModelsList.appendChild(row); }); } // Render Public models list with up/down ordering function renderPublicModels() { if (!el.publicModelsList) return; el.publicModelsList.innerHTML = ''; if (el.publicModelsCount) el.publicModelsCount.textContent = state.publicModels.length.toString(); if (!state.publicModels.length) { const empty = document.createElement('div'); empty.className = 'muted'; empty.textContent = 'No public models configured. Add models to display them to users in the builder.'; el.publicModelsList.appendChild(empty); return; } state.publicModels.forEach((m, idx) => { const row = document.createElement('div'); row.className = 'provider-row slim'; const header = document.createElement('div'); header.className = 'provider-row-header'; const info = document.createElement('div'); info.className = 'model-chip'; // Priority badge const order = document.createElement('span'); order.className = 'pill'; order.textContent = idx === 0 ? 'Primary' : `#${idx + 1}`; if (idx === 0) order.style.background = 'var(--shopify-green)'; info.appendChild(order); if (m.icon) { const img = document.createElement('img'); img.src = m.icon; img.alt = ''; info.appendChild(img); } const label = document.createElement('span'); label.textContent = m.label || m.name; info.appendChild(label); const namePill = document.createElement('span'); namePill.className = 'pill'; namePill.textContent = m.name; info.appendChild(namePill); const tierMeta = document.createElement('span'); tierMeta.className = 'pill'; const tierName = (m.tier || 'free').toUpperCase(); const multiplier = m.tier === 'pro' ? 3 : (m.tier === 'plus' ? 2 : 1); tierMeta.textContent = `${tierName} (${multiplier}x)`; info.appendChild(tierMeta); if (m.supportsMedia) { const mediaBadge = document.createElement('span'); mediaBadge.className = 'pill'; mediaBadge.style.background = 'var(--shopify-green)'; mediaBadge.textContent = 'Media'; info.appendChild(mediaBadge); } header.appendChild(info); const headerActions = document.createElement('div'); headerActions.className = 'provider-row-actions'; // Up button const upBtn = document.createElement('button'); upBtn.className = 'ghost'; upBtn.textContent = '↑'; upBtn.title = 'Move up'; upBtn.disabled = idx === 0; upBtn.addEventListener('click', async () => { const next = [...state.publicModels]; const [item] = next.splice(idx, 1); next.splice(Math.max(0, idx - 1), 0, item); await persistPublicModelsOrder(next); }); headerActions.appendChild(upBtn); // Down button const downBtn = document.createElement('button'); downBtn.className = 'ghost'; downBtn.textContent = '↓'; downBtn.title = 'Move down'; downBtn.disabled = idx === state.publicModels.length - 1; downBtn.addEventListener('click', async () => { const next = [...state.publicModels]; const [item] = next.splice(idx, 1); next.splice(Math.min(state.publicModels.length, idx + 1), 0, item); await persistPublicModelsOrder(next); }); headerActions.appendChild(downBtn); const delBtn = document.createElement('button'); delBtn.className = 'ghost'; delBtn.textContent = 'Delete'; delBtn.addEventListener('click', async () => { delBtn.disabled = true; try { await api(`/api/admin/models/${m.id}?type=public`, { method: 'DELETE' }); await loadConfigured(); } catch (err) { setStatus(err.message, true); } delBtn.disabled = false; }); headerActions.appendChild(delBtn); // Inline icon editor button const editIconBtn = document.createElement('button'); editIconBtn.className = 'ghost'; editIconBtn.textContent = 'Edit icon'; editIconBtn.addEventListener('click', () => { let editor = header.querySelector('.icon-editor'); if (editor) return editor.remove(); editor = document.createElement('div'); editor.className = 'icon-editor'; const sel = document.createElement('select'); const none = document.createElement('option'); none.value = ''; none.textContent = 'No icon'; sel.appendChild(none); (state.icons || []).forEach((iconPath) => { const o = document.createElement('option'); o.value = iconPath; o.textContent = iconPath.replace('/assets/', ''); sel.appendChild(o); }); sel.value = m.icon || ''; editor.appendChild(sel); const saveBtn = document.createElement('button'); saveBtn.className = 'primary'; saveBtn.textContent = 'Save'; saveBtn.addEventListener('click', async () => { saveBtn.disabled = true; try { await persistPublicModelChanges(m.id, { icon: sel.value }); } catch (err) { setStatus(err.message, true); } saveBtn.disabled = false; }); editor.appendChild(saveBtn); const cancelBtn = document.createElement('button'); cancelBtn.className = 'ghost'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => editor.remove()); editor.appendChild(cancelBtn); headerActions.appendChild(editor); }); headerActions.appendChild(editIconBtn); // Supports media checkbox const mediaToggle = document.createElement('label'); mediaToggle.style.display = 'flex'; mediaToggle.style.alignItems = 'center'; mediaToggle.style.gap = '6px'; mediaToggle.style.marginLeft = '8px'; const mediaCheckbox = document.createElement('input'); mediaCheckbox.type = 'checkbox'; mediaCheckbox.checked = m.supportsMedia ?? false; mediaCheckbox.addEventListener('change', async () => { mediaCheckbox.disabled = true; try { await persistPublicModelChanges(m.id, { supportsMedia: mediaCheckbox.checked }); } catch (err) { setStatus(err.message, true); } mediaCheckbox.disabled = false; }); mediaToggle.appendChild(mediaCheckbox); const mediaLabel = document.createElement('span'); mediaLabel.textContent = 'Media'; mediaLabel.style.fontSize = '12px'; mediaLabel.style.color = 'var(--muted)'; mediaToggle.appendChild(mediaLabel); headerActions.appendChild(mediaToggle); // Tier editor button const editTierBtn = document.createElement('button'); editTierBtn.className = 'ghost'; editTierBtn.textContent = 'Edit tier'; editTierBtn.addEventListener('click', () => { let editor = header.querySelector('.tier-editor'); if (editor) return editor.remove(); editor = document.createElement('div'); editor.className = 'tier-editor'; const sel = document.createElement('select'); const options = [ { value: 'free', label: 'Free (1x)' }, { value: 'plus', label: 'Plus (2x)' }, { value: 'pro', label: 'Pro (3x)' } ]; options.forEach((opt) => { const o = document.createElement('option'); o.value = opt.value; o.textContent = opt.label; sel.appendChild(o); }); sel.value = m.tier || 'free'; editor.appendChild(sel); const saveBtn = document.createElement('button'); saveBtn.className = 'primary'; saveBtn.textContent = 'Save'; saveBtn.addEventListener('click', async () => { saveBtn.disabled = true; try { await persistPublicModelChanges(m.id, { tier: sel.value }); } catch (err) { setStatus(err.message, true); } saveBtn.disabled = false; }); editor.appendChild(saveBtn); const cancelBtn = document.createElement('button'); cancelBtn.className = 'ghost'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => editor.remove()); editor.appendChild(cancelBtn); headerActions.appendChild(editor); }); headerActions.appendChild(editTierBtn); header.appendChild(headerActions); row.appendChild(header); el.publicModelsList.appendChild(row); }); } // Legacy renderConfigured for backward compatibility function renderConfigured() { renderOpencodeModels(); renderPublicModels(); } async function persistOpencodeModelChanges(modelId, changes) { setStatus('Saving...'); const model = state.opencodeModels.find((m) => m.id === modelId); if (!model) { setStatus('Model not found', true); return; } const payload = { type: 'opencode', id: modelId, name: model.name, label: model.label || model.name, icon: changes.icon !== undefined ? changes.icon : model.icon, tier: changes.tier !== undefined ? changes.tier : model.tier, supportsMedia: changes.supportsMedia !== undefined ? changes.supportsMedia : model.supportsMedia, }; try { const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) }); const idx = state.opencodeModels.findIndex((m) => m.id === modelId); if (idx >= 0) state.opencodeModels[idx] = { ...state.opencodeModels[idx], ...data.opencodeModel }; renderOpencodeModels(); setStatus('Saved'); setTimeout(() => setStatus(''), 1500); } catch (err) { setStatus(err.message, true); } } async function persistOpencodeModelsOrder(nextModels) { setStatus('Saving order...'); try { const payload = { type: 'opencode', models: nextModels.map(m => ({ id: m.id })) }; const res = await api('/api/admin/models/reorder', { method: 'POST', body: JSON.stringify(payload) }); state.opencodeModels = res.opencodeModels || nextModels; renderOpencodeModels(); setStatus('Saved'); setTimeout(() => setStatus(''), 1500); } catch (err) { setStatus(err.message, true); } } async function persistPublicModelsOrder(nextModels) { setStatus('Saving order...'); try { const payload = { type: 'public', models: nextModels.map(m => ({ id: m.id })) }; const res = await api('/api/admin/models/reorder', { method: 'POST', body: JSON.stringify(payload) }); state.publicModels = res.publicModels || nextModels; renderPublicModels(); setStatus('Saved'); setTimeout(() => setStatus(''), 1500); } catch (err) { setStatus(err.message, true); } } async function persistPublicModelChanges(modelId, changes) { setStatus('Saving...'); const model = state.publicModels.find((m) => m.id === modelId); if (!model) { setStatus('Model not found', true); return; } const payload = { type: 'publicModel', id: modelId, name: model.name, label: model.label || model.name, icon: changes.icon !== undefined ? changes.icon : model.icon, tier: changes.tier !== undefined ? changes.tier : model.tier, supportsMedia: changes.supportsMedia !== undefined ? changes.supportsMedia : model.supportsMedia, }; try { const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) }); const idx = state.publicModels.findIndex((m) => m.id === modelId); if (idx >= 0) state.publicModels[idx] = { ...state.publicModels[idx], ...data.publicModel }; renderConfigured(); setStatus('Saved'); setTimeout(() => setStatus(''), 1500); } catch (err) { setStatus(err.message, true); } } // Render the unified provider chain with up/down controls function renderProviderChain() { if (!el.providerChainList) return; el.providerChainList.innerHTML = ''; if (el.providerChainCount) el.providerChainCount.textContent = state.providerChain.length.toString(); if (!state.providerChain.length) { const empty = document.createElement('div'); empty.className = 'muted'; empty.textContent = 'No provider chain configured. Add providers to enable automatic fallback.'; el.providerChainList.appendChild(empty); return; } state.providerChain.forEach((entry, idx) => { const row = document.createElement('div'); row.className = 'provider-row slim'; const header = document.createElement('div'); header.className = 'provider-row-header'; const info = document.createElement('div'); info.className = 'model-chip'; // Priority badge const order = document.createElement('span'); order.className = 'pill'; order.textContent = idx === 0 ? 'Primary' : `#${idx + 1}`; if (idx === 0) order.style.background = 'var(--shopify-green)'; info.appendChild(order); // Provider badge const providerPill = document.createElement('span'); providerPill.className = 'pill'; providerPill.textContent = entry.provider; providerPill.style.background = 'var(--primary)'; info.appendChild(providerPill); // Model name const modelPill = document.createElement('span'); modelPill.textContent = entry.model; info.appendChild(modelPill); // Limit summary const limitPill = document.createElement('span'); limitPill.className = 'pill'; limitPill.textContent = formatLimitSummary(entry.provider, entry.model); info.appendChild(limitPill); header.appendChild(info); // Actions const actions = document.createElement('div'); actions.className = 'provider-row-actions'; const upBtn = document.createElement('button'); upBtn.className = 'ghost'; upBtn.textContent = '↑'; upBtn.title = 'Move up'; upBtn.disabled = idx === 0; upBtn.addEventListener('click', async () => { const next = [...state.providerChain]; const [item] = next.splice(idx, 1); next.splice(Math.max(0, idx - 1), 0, item); await persistProviderChainOrder(next); }); actions.appendChild(upBtn); const downBtn = document.createElement('button'); downBtn.className = 'ghost'; downBtn.textContent = '↓'; downBtn.title = 'Move down'; downBtn.disabled = idx === state.providerChain.length - 1; downBtn.addEventListener('click', async () => { const next = [...state.providerChain]; const [item] = next.splice(idx, 1); next.splice(Math.min(state.providerChain.length, idx + 1), 0, item); await persistProviderChainOrder(next); }); actions.appendChild(downBtn); const removeBtn = document.createElement('button'); removeBtn.className = 'ghost'; removeBtn.textContent = 'Remove'; removeBtn.addEventListener('click', async () => { if (state.providerChain.length <= 1) { alert('Cannot remove the last provider. Add another provider first.'); return; } const next = state.providerChain.filter((_, i) => i !== idx); await persistProviderChainOrder(next); }); actions.appendChild(removeBtn); header.appendChild(actions); row.appendChild(header); el.providerChainList.appendChild(row); }); } async function persistProviderChainOrder(nextChain) { setProviderChainStatus('Saving order...'); try { const payload = { type: 'providerChain', chain: nextChain }; const res = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) }); state.providerChain = res.providerChain || nextChain; renderProviderChain(); setProviderChainStatus('Saved'); setTimeout(() => setProviderChainStatus(''), 1500); } catch (err) { setProviderChainStatus(err.message, true); } } function normalizePlanChainLocal(chain) { if (!Array.isArray(chain)) return []; const seen = new Set(); const out = []; chain.forEach((entry) => { const provider = (entry?.provider || '').toString().trim().toLowerCase(); if (!PLANNING_PROVIDERS.includes(provider)) return; // `model` is the normalized string used at runtime; `raw` preserves the // exact admin input for display (e.g., "groq/compound-mini"). Prefer // `raw` for showing in inputs, but dedupe keys using the normalized model. const normalizedModel = typeof entry?.model === 'string' ? entry.model.trim() : ''; const displayModel = (typeof entry?.raw === 'string' && entry.raw.trim()) ? entry.raw.trim() : normalizedModel; const key = `${provider}::${normalizedModel || '__any__'}`; if (seen.has(key)) return; seen.add(key); out.push({ provider, model: normalizedModel, raw: displayModel }); }); return out; } function planLimitSummary(provider, modelName) { const cfg = state.providerLimits && state.providerLimits[provider]; if (!cfg) return 'Unlimited'; const isPerModelScope = cfg.scope === 'model'; const hasModelSpecificLimit = isPerModelScope && modelName && cfg.perModel && cfg.perModel[modelName]; const target = hasModelSpecificLimit ? cfg.perModel[modelName] : cfg; const parts = []; if (target.requestsPerMinute) parts.push(`${target.requestsPerMinute}/m`); if (target.tokensPerMinute) parts.push(`${target.tokensPerMinute} tpm`); if (target.requestsPerDay) parts.push(`${target.requestsPerDay}/day`); if (target.tokensPerDay) parts.push(`${target.tokensPerDay} tpd`); if (!parts.length) { if (isPerModelScope && !hasModelSpecificLimit) { return 'No limit for this model'; } return 'Unlimited'; } const limitStr = parts.join(' · '); return hasModelSpecificLimit ? `${limitStr} (per-model)` : limitStr; } async function persistPlanChain(nextChain) { if (!el.planPriorityList) return; setPlanChainStatus('Saving...'); try { const payload = { planningChain: nextChain }; const res = await api('/api/admin/plan-settings', { method: 'POST', body: JSON.stringify(payload) }); const normalized = normalizePlanChainLocal(res.settings?.planningChain || nextChain); state.planSettings = { ...state.planSettings, ...(res.settings || {}), planningChain: normalized }; renderPlanPriority(); setPlanChainStatus('Saved'); setTimeout(() => setPlanChainStatus(''), 1500); } catch (err) { setPlanChainStatus(err.message, true); } } function renderPlanPriority() { if (!el.planPriorityList) return; const chain = normalizePlanChainLocal(state.planSettings?.planningChain || []); el.planPriorityList.innerHTML = ''; if (!chain.length) { const empty = document.createElement('div'); empty.className = 'muted'; empty.textContent = 'No planning models configured. Add one to enable planning fallbacks.'; el.planPriorityList.appendChild(empty); } chain.forEach((entry, idx) => { const row = document.createElement('div'); row.className = 'provider-row slim'; const header = document.createElement('div'); header.className = 'provider-row-header'; const info = document.createElement('div'); info.className = 'model-chip'; const order = document.createElement('span'); order.className = 'pill'; order.textContent = `Priority #${idx + 1}`; info.appendChild(order); const providerSelect = document.createElement('select'); PLANNING_PROVIDERS.forEach((provider) => { const opt = document.createElement('option'); opt.value = provider; opt.textContent = provider; providerSelect.appendChild(opt); }); providerSelect.value = entry.provider; providerSelect.addEventListener('change', () => { const next = normalizePlanChainLocal(chain.map((c, i) => (i === idx ? { ...c, provider: providerSelect.value } : c))); persistPlanChain(next); }); info.appendChild(providerSelect); const modelInput = document.createElement('input'); modelInput.type = 'text'; modelInput.placeholder = 'Model id (supports OpenRouter, Mistral, Google, Groq, NVIDIA)'; // Prefer showing the exact user input (`raw`) when available, otherwise show // the normalized `model` value. modelInput.value = entry.raw || entry.model; modelInput.setAttribute('list', ensureAvailableDatalist().id); modelInput.addEventListener('blur', () => { const val = modelInput.value.trim(); const next = normalizePlanChainLocal(chain.map((c, i) => (i === idx ? { ...c, model: val, raw: val } : c))); persistPlanChain(next); }); info.appendChild(modelInput); const limitPill = document.createElement('span'); limitPill.className = 'pill'; limitPill.textContent = planLimitSummary(entry.provider, entry.model); info.appendChild(limitPill); header.appendChild(info); const actions = document.createElement('div'); actions.className = 'provider-row-actions'; const upBtn = document.createElement('button'); upBtn.className = 'ghost'; upBtn.textContent = '↑'; upBtn.title = 'Move up'; upBtn.disabled = idx === 0; upBtn.addEventListener('click', () => { const next = [...chain]; const [item] = next.splice(idx, 1); next.splice(Math.max(0, idx - 1), 0, item); persistPlanChain(next); }); actions.appendChild(upBtn); const downBtn = document.createElement('button'); downBtn.className = 'ghost'; downBtn.textContent = '↓'; downBtn.title = 'Move down'; downBtn.disabled = idx === chain.length - 1; downBtn.addEventListener('click', () => { const next = [...chain]; const [item] = next.splice(idx, 1); next.splice(Math.min(chain.length, idx + 1), 0, item); persistPlanChain(next); }); actions.appendChild(downBtn); const removeBtn = document.createElement('button'); removeBtn.className = 'ghost'; removeBtn.textContent = 'Remove'; removeBtn.addEventListener('click', () => { const next = chain.filter((_, i) => i !== idx); persistPlanChain(next); }); actions.appendChild(removeBtn); header.appendChild(actions); row.appendChild(header); el.planPriorityList.appendChild(row); }); } function renderProviderUsage() { if (!el.providerUsage) return; el.providerUsage.innerHTML = ''; if (!state.providerUsage.length) { const empty = document.createElement('div'); empty.className = 'muted'; empty.textContent = 'No usage recorded yet.'; el.providerUsage.appendChild(empty); return; } function createUsageRow(provider, label, limits, usage) { const row = document.createElement('div'); row.className = 'admin-row'; const left = document.createElement('div'); left.style.display = 'flex'; left.style.flexDirection = 'column'; left.style.minWidth = '200px'; left.innerHTML = `${provider} ${label}`; const progress = document.createElement('div'); progress.style.display = 'flex'; progress.style.flexDirection = 'column'; progress.style.gap = '6px'; progress.style.flex = '1'; const rows = [ ['Tokens (1m)', usage.tokensLastMinute || 0, limits.tokensPerMinute || 0], ['Tokens (24h)', usage.tokensLastDay || 0, limits.tokensPerDay || 0], ['Requests (1m)', usage.requestsLastMinute || 0, limits.requestsPerMinute || 0], ['Requests (24h)', usage.requestsLastDay || 0, limits.requestsPerDay || 0], ]; rows.forEach(([labelText, used, limit]) => { const wrap = document.createElement('div'); wrap.style.display = 'flex'; wrap.style.flexDirection = 'column'; const labelEl = document.createElement('div'); labelEl.style.display = 'flex'; labelEl.style.justifyContent = 'space-between'; labelEl.style.fontSize = '12px'; labelEl.innerHTML = `${labelText}${used}${limit > 0 ? ` / ${limit}` : ''}`; wrap.appendChild(labelEl); if (limit > 0) { const barOuter = document.createElement('div'); barOuter.style.background = 'var(--border)'; barOuter.style.height = '6px'; barOuter.style.borderRadius = '6px'; const barInner = document.createElement('div'); barInner.style.height = '6px'; barInner.style.borderRadius = '6px'; const pct = Math.min(100, parseFloat(((used / limit) * 100).toFixed(1))); barInner.style.width = `${pct}%`; barInner.style.background = pct > 90 ? 'var(--danger)' : 'var(--primary)'; barOuter.appendChild(barInner); wrap.appendChild(barOuter); } progress.appendChild(wrap); }); row.appendChild(left); row.appendChild(progress); return row; } state.providerUsage.forEach((entry) => { const isPerModel = entry.scope === 'model'; const perModelLimits = entry.perModelLimits || {}; const perModelUsage = (entry.usage && entry.usage.perModel) || {}; const modelNames = isPerModel ? [...new Set([...Object.keys(perModelLimits), ...Object.keys(perModelUsage)])] : []; // If per-model scope is enabled and we have models, show them individually if (isPerModel && modelNames.length > 0) { modelNames.forEach((modelName) => { const limits = perModelLimits[modelName] || {}; const usage = perModelUsage[modelName] || {}; const row = createUsageRow(entry.provider, modelName, limits, usage); el.providerUsage.appendChild(row); }); } else { // Provider-level scope or no models yet - show aggregate const limits = entry.limits || {}; const usage = entry.usage || {}; const row = createUsageRow(entry.provider, entry.scope, limits, usage); el.providerUsage.appendChild(row); } }); } function renderProviderOptions() { if (!el.limitProvider) return; const providersFromState = Array.isArray(state.providerOptions) && state.providerOptions.length ? [...state.providerOptions] : null; let providers = providersFromState || Object.keys(state.providerLimits || {}); const current = el.limitProvider.value; el.limitProvider.innerHTML = ''; if (!providers.length) providers = [...DEFAULT_PROVIDERS]; providers.forEach((provider, idx) => { const opt = document.createElement('option'); opt.value = provider; opt.textContent = provider; if (provider === current || (!current && idx === 0)) opt.selected = true; el.limitProvider.appendChild(opt); }); } function renderLimitModelOptions(provider) { // If we're on the plan page, allow free-text model entry (admins can type any model id) if (pageType === 'plan') { if (!el.limitModelInput) return; // show input and hide the select if (el.limitModel) el.limitModel.style.display = 'none'; el.limitModelInput.style.display = ''; // Populate datalist with discovered models to help typing syncAvailableModelDatalist(); // set current value from configured per-model limit if present const cfg = state.providerLimits && state.providerLimits[provider] ? state.providerLimits[provider] : {}; const modelKey = el.limitModelInput.value || ''; // Do not overwrite user's typing; keep existing value if (!el.limitModelInput.value && cfg.perModel) { // nothing to prefill unless there's exactly one per-model configured const keys = Object.keys(cfg.perModel || {}); if (keys.length === 1) el.limitModelInput.value = keys[0]; } return; } if (!el.limitModel) return; el.limitModel.style.display = ''; if (el.limitModelInput) el.limitModelInput.style.display = 'none'; const current = el.limitModel.value; const modelsFromProvider = (state.providerModels && state.providerModels[provider]) ? state.providerModels[provider] : []; const combined = new Set(modelsFromProvider); getAvailableModelNames().forEach((m) => combined.add(m)); const sorted = Array.from(combined).filter(Boolean).sort((a, b) => a.localeCompare(b)); el.limitModel.innerHTML = ''; const anyOpt = document.createElement('option'); anyOpt.value = ''; anyOpt.textContent = 'Any model'; el.limitModel.appendChild(anyOpt); sorted.forEach((model) => { const opt = document.createElement('option'); opt.value = model; opt.textContent = model; el.limitModel.appendChild(opt); }); if (current && sorted.includes(current)) el.limitModel.value = current; } async function loadAvailable() { const data = await api('/api/admin/available-models'); state.available = data.models || []; renderAvailable(); syncAvailableModelDatalist(); if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter'); } async function loadIcons() { const data = await api('/api/admin/icons'); state.icons = data.icons || []; renderIcons(); } async function loadConfigured() { const data = await api('/api/admin/models'); // Handle new unified structure state.opencodeModels = data.opencodeModels || []; state.publicModels = data.publicModels || []; state.configured = data.models || []; // Legacy support renderOpencodeModels(); renderPublicModels(); syncAvailableModelDatalist(); if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter'); } function populateOpencodeModelSelect() { if (!el.opencodeModelSelect) return; el.opencodeModelSelect.innerHTML = ''; // Add placeholder const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = '-- Select a model --'; el.opencodeModelSelect.appendChild(placeholder); // Add available models from OpenCode (state.available || []).forEach((m) => { const opt = document.createElement('option'); opt.value = m.name || m.id || m; opt.textContent = m.label || m.name || m.id || m; el.opencodeModelSelect.appendChild(opt); }); // Also include any provider-specific models Object.values(state.providerModels || {}).forEach((arr) => { (arr || []).forEach((name) => { if (name && !el.opencodeModelSelect.querySelector(`option[value="${name}"]`)) { const opt = document.createElement('option'); opt.value = name; opt.textContent = name; el.opencodeModelSelect.appendChild(opt); } }); }); } async function loadOpenRouterSettings() { if (!el.orForm) return; try { const data = await api('/api/admin/openrouter-settings'); if (el.orPrimary) el.orPrimary.value = data.primaryModel || ''; if (el.orBackup1) el.orBackup1.value = data.backupModel1 || ''; if (el.orBackup2) el.orBackup2.value = data.backupModel2 || ''; if (el.orBackup3) el.orBackup3.value = data.backupModel3 || ''; } catch (err) { setOrStatus(err.message, true); } } async function loadMistralSettings() { if (!el.mistralForm) return; try { const data = await api('/api/admin/mistral-settings'); if (el.mistralPrimary) el.mistralPrimary.value = data.primaryModel || ''; if (el.mistralBackup1) el.mistralBackup1.value = data.backupModel1 || ''; if (el.mistralBackup2) el.mistralBackup2.value = data.backupModel2 || ''; if (el.mistralBackup3) el.mistralBackup3.value = data.backupModel3 || ''; } catch (err) { setMistralStatus(err.message, true); } } function populateLimitForm(provider, scope = 'provider') { if (!el.limitProvider) return; const selectedProvider = provider || el.limitProvider.value || 'openrouter'; const selectedScope = scope || el.limitScope?.value || 'provider'; const cfg = state.providerLimits[selectedProvider] || {}; renderLimitModelOptions(selectedProvider); // prefer the free-text input on the plan page const modelKey = (pageType === 'plan' && el.limitModelInput) ? (el.limitModelInput.value || '') : (el.limitModel ? el.limitModel.value : ''); const target = selectedScope === 'model' && modelKey && cfg.perModel && cfg.perModel[modelKey] ? cfg.perModel[modelKey] : cfg; if (el.limitProvider) el.limitProvider.value = selectedProvider; if (el.limitScope) el.limitScope.value = selectedScope; if (pageType === 'plan' && el.limitModelInput) { el.limitModelInput.value = selectedScope === 'model' ? (modelKey || '') : ''; } else if (el.limitModel) { el.limitModel.value = selectedScope === 'model' ? (modelKey || '') : ''; } if (el.limitTpm) el.limitTpm.value = target.tokensPerMinute ?? ''; if (el.limitTph) el.limitTph.value = target.tokensPerHour ?? ''; if (el.limitTpd) el.limitTpd.value = target.tokensPerDay ?? ''; if (el.limitRpm) el.limitRpm.value = target.requestsPerMinute ?? ''; if (el.limitRph) el.limitRph.value = target.requestsPerHour ?? ''; if (el.limitRpd) el.limitRpd.value = target.requestsPerDay ?? ''; } async function loadProviderLimits() { if (!el.providerUsage && !el.providerLimitForm) return; try { const data = await api('/api/admin/provider-limits'); state.providerLimits = data.limits || {}; state.providerOptions = data.providers || []; state.providerModels = data.providerModels || {}; (state.providerOptions || []).forEach((provider) => { if (provider && !state.providerLimits[provider]) state.providerLimits[provider] = {}; }); DEFAULT_PROVIDERS.forEach((p) => { if (!state.providerLimits[p]) state.providerLimits[p] = {}; }); state.providerUsage = data.usage || []; renderProviderOptions(); populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider'); renderProviderUsage(); // refresh datalist with provider-specific models syncAvailableModelDatalist(); renderPlanPriority(); } catch (err) { setProviderLimitStatus(err.message, true); } } // --- Plan tokens UI --- async function loadPlanTokens() { if (!el.planTokensTable) return; try { const data = await api('/api/admin/plan-tokens'); state.planTokens = data.limits || {}; renderPlanTokens(); } catch (err) { if (el.planTokensStatus) el.planTokensStatus.textContent = String(err.message || err); } } function renderPlanTokens() { if (!el.planTokensTable) return; el.planTokensTable.innerHTML = ''; const plansOrder = ['hobby', 'starter', 'business', 'enterprise']; plansOrder.forEach((plan) => { const card = document.createElement('div'); card.className = 'admin-row'; const left = document.createElement('div'); left.style.display = 'flex'; left.style.flexDirection = 'column'; left.style.minWidth = '180px'; const title = document.createElement('strong'); title.textContent = plan; left.appendChild(title); left.style.marginBottom = '8px'; const input = document.createElement('input'); input.type = 'number'; input.min = '0'; input.step = '1'; input.value = (state.planTokens && typeof state.planTokens[plan] === 'number') ? String(state.planTokens[plan]) : ''; input.dataset.plan = plan; input.placeholder = 'Token limit'; input.style.width = '200px'; const wrapper = document.createElement('div'); wrapper.style.display = 'flex'; wrapper.style.flexDirection = 'column'; wrapper.style.gap = '4px'; const label = document.createElement('div'); label.textContent = 'Token Limit'; label.style.fontSize = '12px'; label.style.color = 'var(--muted)'; wrapper.appendChild(label); wrapper.appendChild(input); card.appendChild(left); card.appendChild(wrapper); el.planTokensTable.appendChild(card); }); } async function savePlanTokens() { if (!el.planTokensTable) return; if (el.savePlanTokens) el.savePlanTokens.disabled = true; if (el.planTokensStatus) el.planTokensStatus.textContent = 'Saving...'; const rows = el.planTokensTable.querySelectorAll('input[data-plan]'); const payload = {}; rows.forEach((input) => { const plan = input.dataset.plan; const num = input.value ? Number(input.value) : 0; payload[plan] = Number.isFinite(num) ? Math.max(0, Math.round(num)) : 0; }); try { const res = await api('/api/admin/plan-tokens', { method: 'POST', body: JSON.stringify({ limits: payload }) }); state.planTokens = res.limits || payload; renderPlanTokens(); if (el.planTokensStatus) el.planTokensStatus.textContent = 'Saved'; setTimeout(() => { if (el.planTokensStatus) el.planTokensStatus.textContent = ''; }, 1400); } catch (err) { if (el.planTokensStatus) el.planTokensStatus.textContent = err.message || String(err); } finally { if (el.savePlanTokens) el.savePlanTokens.disabled = false; } } // --- Token rates UI --- async function loadTokenRates() { if (!el.tokenRateUsd && !el.tokenRateGbp && !el.tokenRateEur) return; try { const data = await api('/api/admin/token-rates'); state.tokenRates = data.rates || {}; renderTokenRates(); } catch (err) { if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = String(err.message || err); } } function renderTokenRates() { if (el.tokenRateUsd) el.tokenRateUsd.value = String(state.tokenRates?.usd ?? ''); if (el.tokenRateGbp) el.tokenRateGbp.value = String(state.tokenRates?.gbp ?? ''); if (el.tokenRateEur) el.tokenRateEur.value = String(state.tokenRates?.eur ?? ''); } async function saveTokenRates() { if (!el.saveTokenRates) return; if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = 'Saving...'; el.saveTokenRates.disabled = true; const rates = { usd: Number(el.tokenRateUsd?.value || 0), gbp: Number(el.tokenRateGbp?.value || 0), eur: Number(el.tokenRateEur?.value || 0), }; Object.keys(rates).forEach((key) => { const value = rates[key]; rates[key] = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0; }); try { const res = await api('/api/admin/token-rates', { method: 'POST', body: JSON.stringify({ rates }) }); state.tokenRates = res.rates || rates; renderTokenRates(); if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = 'Saved'; setTimeout(() => { if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = ''; }, 1400); } catch (err) { if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = err.message || String(err); } finally { el.saveTokenRates.disabled = false; } } async function loadPlanProviderSettings() { if (!el.planProviderForm && !el.planPriorityList) return; try { const data = await api('/api/admin/plan-settings'); state.planSettings = { provider: 'openrouter', planningChain: [], ...(data || {}), }; if (el.planProvider) el.planProvider.value = state.planSettings.provider || 'openrouter'; renderPlanPriority(); } catch (err) { if (el.planProviderForm) setPlanProviderStatus(err.message, true); if (el.planPriorityList) setPlanChainStatus(err.message, true); } } function formatDisplayDate(value) { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return '—'; return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } function renderAccounts() { if (!el.accountsTable) return; el.accountsTable.innerHTML = ''; if (el.accountsCount) el.accountsCount.textContent = `${state.accounts.length} accounts`; if (!state.accounts.length) { const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 9; cell.textContent = 'No accounts found.'; cell.className = 'muted'; cell.style.padding = '12px'; row.appendChild(cell); el.accountsTable.appendChild(row); return; } state.accounts.forEach((acct) => { const row = document.createElement('tr'); row.style.borderBottom = '1px solid var(--border)'; [ acct.email || 'Unknown', acct.plan || 'starter', acct.billingStatus || 'active', acct.billingEmail || '—', formatDisplayDate(acct.subscriptionRenewsAt), formatDisplayDate(acct.createdAt), formatDisplayDate(acct.lastLoginAt), ].forEach((value) => { const cell = document.createElement('td'); cell.style.padding = '10px 8px'; cell.textContent = value; row.appendChild(cell); }); const tokenUsage = acct.tokenUsage || {}; const tokensUsed = tokenUsage.used || 0; const tokensLimit = tokenUsage.limit || 0; const tokenOverride = tokenUsage.tokenOverride; const tokensDisplay = `${tokensUsed.toLocaleString()} / ${tokensLimit.toLocaleString()}${tokenOverride !== null && tokenOverride !== undefined ? ' (override)' : ''}`; const tokenCell = document.createElement('td'); tokenCell.style.padding = '10px 8px'; tokenCell.textContent = tokensDisplay; tokenCell.title = tokenOverride !== null && tokenOverride !== undefined ? `Manual override: ${tokenOverride.toLocaleString()} tokens` : 'Plan-based limit'; row.appendChild(tokenCell); const actionsCell = document.createElement('td'); actionsCell.style.padding = '10px 8px'; actionsCell.style.display = 'flex'; actionsCell.style.gap = '8px'; const changeBtn = document.createElement('button'); changeBtn.className = 'ghost'; changeBtn.textContent = 'Change Plan'; changeBtn.title = 'Change user plan without payment'; changeBtn.addEventListener('click', () => changePlan(acct)); actionsCell.appendChild(changeBtn); const tokensBtn = document.createElement('button'); tokensBtn.className = 'ghost'; tokensBtn.textContent = 'Set Tokens'; tokensBtn.title = 'Manually set token limit for this user'; tokensBtn.addEventListener('click', () => setTokens(acct)); actionsCell.appendChild(tokensBtn); const deleteBtn = document.createElement('button'); deleteBtn.className = 'danger'; deleteBtn.textContent = 'Delete'; deleteBtn.title = 'Permanently delete user and all data'; deleteBtn.addEventListener('click', () => deleteUser(acct)); actionsCell.appendChild(deleteBtn); row.appendChild(actionsCell); el.accountsTable.appendChild(row); }); } async function changePlan(acct) { const plans = ['hobby', 'starter', 'business', 'enterprise']; const currentPlan = acct.plan || 'hobby'; const nextPlan = prompt(`Change plan for ${acct.email}\nCurrent plan: ${currentPlan}\n\nEnter new plan (hobby, starter, business, enterprise):`, currentPlan); if (nextPlan === null) return; const normalized = nextPlan.trim().toLowerCase(); if (!plans.includes(normalized)) { alert('Invalid plan. Please enter hobby, starter, business, or enterprise.'); return; } if (normalized === currentPlan) return; if (!confirm(`Are you sure you want to change ${acct.email}'s plan to ${normalized.toUpperCase()}? This will take effect immediately without charging them.`)) { return; } setStatus(`Updating plan for ${acct.email}...`); try { await api('/api/admin/accounts/plan', { method: 'POST', body: JSON.stringify({ userId: acct.id, plan: normalized }) }); setStatus('Plan updated successfully'); await loadAccounts(); setTimeout(() => setStatus(''), 3000); } catch (err) { setStatus(err.message, true); } } async function deleteUser(acct) { const confirmation = window.confirm( `Are you absolutely sure you want to permanently delete ${acct.email}?\n\n` + `This will:\n` + `• Delete the user account permanently\n` + `• Remove all their apps/sessions\n` + `• Delete all their workspace data\n` + `• This action CANNOT be undone!\n\n` + `Type DELETE to confirm:` ); if (!confirmation) return; const confirmationText = window.prompt( 'This action cannot be undone. Type DELETE to confirm permanently deleting this user:' ); if (confirmationText !== 'DELETE') { alert('Deletion cancelled. You must type DELETE to confirm.'); return; } setStatus(`Permanently deleting ${acct.email}...`); try { await api('/api/admin/accounts', { method: 'DELETE', body: JSON.stringify({ userId: acct.id }) }); setStatus('User permanently deleted'); await loadAccounts(); setTimeout(() => setStatus(''), 3000); } catch (err) { setStatus(err.message, true); } } async function setTokens(acct) { const tokenUsage = acct.tokenUsage || {}; const currentLimit = tokenUsage.limit || 0; const currentUsed = tokenUsage.used || 0; const currentRemaining = tokenUsage.remaining || 0; const hasOverride = tokenUsage.tokenOverride !== null && tokenUsage.tokenOverride !== undefined; const currentOverride = hasOverride ? tokenUsage.tokenOverride : ''; const modeMessage = `Manage tokens for ${acct.email}\n\n` + `Current Status:\n` + ` Plan: ${acct.plan || 'starter'}\n` + ` Limit: ${currentLimit.toLocaleString()} tokens\n` + ` Used: ${currentUsed.toLocaleString()} tokens\n` + ` Remaining: ${currentRemaining.toLocaleString()} tokens\n` + `${hasOverride ? ` Override: ${currentOverride.toLocaleString()} tokens\n` : ''}\n` + `Choose mode:\n` + ` 1 = Set LIMIT (override plan limit)\n` + ` 2 = Set USED (directly set tokens consumed)\n` + ` 3 = Set REMAINING (set tokens left to use)`; const modeInput = prompt(modeMessage, '1'); if (modeInput === null) return; const mode = parseInt(modeInput.trim(), 10); if (![1, 2, 3].includes(mode)) { alert('Invalid mode. Please enter 1, 2, or 3.'); return; } let promptText = ''; let defaultValue = ''; let confirmText = ''; if (mode === 1) { promptText = `Set token LIMIT for ${acct.email}\n\n` + `Current limit: ${currentLimit.toLocaleString()} tokens\n` + `${hasOverride ? `Current override: ${currentOverride.toLocaleString()}\n` : ''}\n` + `Enter new limit (0 to remove override):`; defaultValue = currentOverride; } else if (mode === 2) { promptText = `Set tokens USED for ${acct.email}\n\n` + `Current used: ${currentUsed.toLocaleString()} tokens\n` + `Limit: ${currentLimit.toLocaleString()} tokens\n\n` + `Enter new usage amount:`; defaultValue = currentUsed; } else { promptText = `Set tokens REMAINING for ${acct.email}\n\n` + `Current remaining: ${currentRemaining.toLocaleString()} tokens\n` + `Limit: ${currentLimit.toLocaleString()} tokens\n\n` + `Enter remaining tokens (will calculate usage):`; defaultValue = currentRemaining; } const tokenInput = prompt(promptText, String(defaultValue)); if (tokenInput === null) return; const tokens = parseInt(tokenInput.trim(), 10); if (isNaN(tokens) || tokens < 0) { alert('Invalid token amount. Please enter a non-negative number.'); return; } if (mode === 1) { confirmText = `Set ${acct.email}'s token LIMIT to ${tokens.toLocaleString()}?`; } else if (mode === 2) { confirmText = `Set ${acct.email}'s tokens USED to ${tokens.toLocaleString()}?`; } else { confirmText = `Set ${acct.email}'s tokens REMAINING to ${tokens.toLocaleString()}?`; } if (!confirm(confirmText)) { return; } const modeNames = { 1: 'limit', 2: 'usage', 3: 'remaining' }; setStatus(`Updating ${modeNames[mode]} for ${acct.email}...`); try { await api('/api/admin/accounts/tokens', { method: 'POST', body: JSON.stringify({ userId: acct.id, tokens: tokens, mode: modeNames[mode] }) }); setStatus(`Token ${modeNames[mode]} updated successfully`); await loadAccounts(); setTimeout(() => setStatus(''), 3000); } catch (err) { setStatus(err.message, true); } } async function loadAccounts() { if (!el.accountsTable) return; setStatus('Loading accounts...'); try { const data = await api('/api/admin/accounts'); state.accounts = data.accounts || []; renderAccounts(); setStatus(''); } catch (err) { setStatus(err.message, true); } } function renderAffiliates() { if (!el.affiliatesTable) return; el.affiliatesTable.innerHTML = ''; if (el.affiliatesCount) el.affiliatesCount.textContent = `${state.affiliates.length} affiliates`; if (!state.affiliates.length) { const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 8; cell.textContent = 'No affiliate accounts found.'; cell.className = 'muted'; cell.style.padding = '12px'; row.appendChild(cell); el.affiliatesTable.appendChild(row); return; } state.affiliates.forEach((aff) => { const row = document.createElement('tr'); row.style.borderBottom = '1px solid var(--border)'; const emailCell = document.createElement('td'); emailCell.style.padding = '10px 8px'; emailCell.textContent = aff.email || 'Unknown'; row.appendChild(emailCell); const nameCell = document.createElement('td'); nameCell.style.padding = '10px 8px'; nameCell.textContent = aff.name || '—'; row.appendChild(nameCell); const commissionCell = document.createElement('td'); commissionCell.style.padding = '10px 8px'; commissionCell.textContent = `${((aff.commissionRate ?? 0.075) * 100).toFixed(1)}%`; row.appendChild(commissionCell); const earningsCell = document.createElement('td'); earningsCell.style.padding = '10px 8px'; const earningsTotal = aff.earnings?.total || 0; earningsCell.textContent = `$${earningsTotal.toFixed(2)}`; row.appendChild(earningsCell); const linksCell = document.createElement('td'); linksCell.style.padding = '10px 8px'; const linksCount = Array.isArray(aff.trackingLinks) ? aff.trackingLinks.length : 0; linksCell.textContent = linksCount > 0 ? `${linksCount} link${linksCount > 1 ? 's' : ''}` : '—'; row.appendChild(linksCell); const createdCell = document.createElement('td'); createdCell.style.padding = '10px 8px'; createdCell.textContent = formatDisplayDate(aff.createdAt); row.appendChild(createdCell); const lastLoginCell = document.createElement('td'); lastLoginCell.style.padding = '10px 8px'; lastLoginCell.textContent = formatDisplayDate(aff.lastLoginAt); row.appendChild(lastLoginCell); const actionsCell = document.createElement('td'); actionsCell.style.padding = '10px 8px'; actionsCell.style.display = 'flex'; actionsCell.style.gap = '8px'; const viewLinksBtn = document.createElement('button'); viewLinksBtn.className = 'ghost'; viewLinksBtn.textContent = 'View Links'; viewLinksBtn.title = 'View tracking links'; viewLinksBtn.addEventListener('click', () => viewAffiliateLinks(aff)); actionsCell.appendChild(viewLinksBtn); const deleteBtn = document.createElement('button'); deleteBtn.className = 'danger'; deleteBtn.textContent = 'Delete'; deleteBtn.title = 'Permanently delete affiliate account'; deleteBtn.addEventListener('click', () => deleteAffiliate(aff)); actionsCell.appendChild(deleteBtn); row.appendChild(actionsCell); el.affiliatesTable.appendChild(row); }); } function viewAffiliateLinks(aff) { if (!aff.trackingLinks || !aff.trackingLinks.length) { alert('No tracking links for this affiliate.'); return; } const linksList = aff.trackingLinks.map(l => `${l.code} → ${l.targetPath || '/'}`).join('\n'); alert(`Tracking links for ${aff.email}:\n\n${linksList}`); } async function deleteAffiliate(aff) { const confirmation = window.confirm( `Are you absolutely sure you want to permanently delete affiliate ${aff.email}?\n\n` + `This will:\n` + `• Delete the affiliate account permanently\n` + `• All tracking links will stop working\n` + `• Commission tracking for past referrals will remain\n` + `• This action CANNOT be undone!\n\n` + `Type DELETE to confirm:` ); if (!confirmation) return; const confirmationText = window.prompt( 'This action cannot be undone. Type DELETE to confirm permanently deleting this affiliate:' ); if (confirmationText !== 'DELETE') { alert('Deletion cancelled. You must type DELETE to confirm.'); return; } setStatus(`Permanently deleting affiliate ${aff.email}...`); try { await api('/api/admin/affiliates', { method: 'DELETE', body: JSON.stringify({ affiliateId: aff.id }) }); setStatus('Affiliate permanently deleted'); await loadAffiliates(); setTimeout(() => setStatus(''), 3000); } catch (err) { setStatus(err.message, true); } } async function loadAffiliates() { if (!el.affiliatesTable) return; setStatus('Loading affiliates...'); try { const data = await api('/api/admin/affiliates'); state.affiliates = data.affiliates || []; renderAffiliates(); setStatus(''); } catch (err) { setStatus(err.message, true); } } async function loadWithdrawals() { if (!el.withdrawalsTable) return; setStatus('Loading withdrawals...'); try { const data = await api('/api/admin/withdrawals'); state.withdrawals = data.withdrawals || []; renderWithdrawals(); setStatus(''); } catch (err) { setStatus(err.message, true); } } function renderWithdrawals() { if (!el.withdrawalsTable) return; el.withdrawalsTable.innerHTML = ''; if (el.withdrawalsCount) el.withdrawalsCount.textContent = `${state.withdrawals.length} requests`; if (!state.withdrawals.length) { const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 7; cell.textContent = 'No withdrawal requests found.'; cell.className = 'muted'; cell.style.padding = '12px'; row.appendChild(cell); el.withdrawalsTable.appendChild(row); return; } state.withdrawals.forEach((w) => { const row = document.createElement('tr'); row.style.borderBottom = '1px solid var(--border)'; const dateCell = document.createElement('td'); dateCell.style.padding = '10px 8px'; dateCell.textContent = formatDisplayDate(w.createdAt); row.appendChild(dateCell); const affiliateCell = document.createElement('td'); affiliateCell.style.padding = '10px 8px'; affiliateCell.textContent = w.affiliateEmail || 'Unknown'; row.appendChild(affiliateCell); const paypalCell = document.createElement('td'); paypalCell.style.padding = '10px 8px'; paypalCell.textContent = w.paypalEmail || '—'; row.appendChild(paypalCell); const amountCell = document.createElement('td'); amountCell.style.padding = '10px 8px'; amountCell.textContent = `$${Number(w.amount || 0).toFixed(2)}`; row.appendChild(amountCell); const currencyCell = document.createElement('td'); currencyCell.style.padding = '10px 8px'; currencyCell.textContent = w.currency || 'USD'; row.appendChild(currencyCell); const statusCell = document.createElement('td'); statusCell.style.padding = '10px 8px'; const statusBadge = document.createElement('span'); statusBadge.className = 'pill'; statusBadge.textContent = w.status || 'pending'; statusBadge.style.background = w.status === 'done' ? 'var(--success)' : 'var(--warning)'; statusCell.appendChild(statusBadge); row.appendChild(statusCell); const actionsCell = document.createElement('td'); actionsCell.style.padding = '10px 8px'; actionsCell.style.display = 'flex'; actionsCell.style.gap = '8px'; if (w.status === 'pending') { const markDoneBtn = document.createElement('button'); markDoneBtn.className = 'ghost'; markDoneBtn.textContent = 'Mark Done'; markDoneBtn.title = 'Mark withdrawal as completed'; markDoneBtn.addEventListener('click', () => updateWithdrawalStatus(w, 'done')); actionsCell.appendChild(markDoneBtn); } const detailsBtn = document.createElement('button'); detailsBtn.className = 'ghost'; detailsBtn.textContent = 'Details'; detailsBtn.title = 'View withdrawal details'; detailsBtn.addEventListener('click', () => viewWithdrawalDetails(w)); actionsCell.appendChild(detailsBtn); row.appendChild(actionsCell); el.withdrawalsTable.appendChild(row); }); } function viewWithdrawalDetails(w) { alert(`Withdrawal Details:\n\n` + `Date: ${new Date(w.createdAt).toLocaleString()}\n` + `Affiliate: ${w.affiliateEmail || 'Unknown'}\n` + `PayPal Email: ${w.paypalEmail || '—'}\n` + `Amount: $${Number(w.amount || 0).toFixed(2)}\n` + `Currency: ${w.currency || 'USD'}\n` + `Status: ${w.status || 'pending'}`); } async function updateWithdrawalStatus(withdrawal, newStatus) { setStatus('Updating withdrawal status...'); try { await api('/api/admin/withdrawals', { method: 'PUT', body: JSON.stringify({ withdrawalId: withdrawal.id, status: newStatus }) }); setStatus(`Withdrawal marked as ${newStatus}`); await loadWithdrawals(); setTimeout(() => setStatus(''), 3000); } catch (err) { setStatus(err.message, true); } } async function init() { console.log('init() called'); try { const loaders = [ () => ((el.availableModels || el.planPriorityList) ? loadAvailable() : null), () => ((el.iconSelect || el.iconList) ? loadIcons() : null), () => ((el.opencodeModelsList || el.publicModelsList) ? loadConfigured() : null), () => (el.orForm ? loadOpenRouterSettings() : null), () => (el.mistralForm ? loadMistralSettings() : null), () => ((el.autoModelForm || el.planProviderForm || el.planPriorityList) ? loadPlanProviderSettings() : null), () => (el.accountsTable ? loadAccounts() : null), () => (el.affiliatesTable ? loadAffiliates() : null), () => (el.withdrawalsTable ? loadWithdrawals() : null), () => (el.planTokensTable ? loadPlanTokens() : null), () => ((el.tokenRateUsd || el.tokenRateGbp || el.tokenRateEur) ? loadTokenRates() : null), () => ((el.providerUsage || el.providerLimitForm) ? loadProviderLimits() : null), () => (el.externalTestingConfig ? loadExternalTestingStatus() : null), ]; await Promise.all(loaders.map((fn) => fn()).filter(Boolean)); // Populate opencode model select after loadAvailable() has completed if (el.opencodeModelSelect) { populateOpencodeModelSelect(); } // Always try to load provider limits if not already loaded (needed for backup dropdown) if (!state.providerModels || Object.keys(state.providerModels).length === 0) { try { const data = await api('/api/admin/provider-limits'); state.providerLimits = data.limits || {}; state.providerOptions = data.providers || []; state.providerModels = data.providerModels || {}; state.opencodeBackupModel = data.opencodeBackupModel || ''; } catch (e) { console.warn('Failed to load provider limits for backup dropdown:', e); } } // Ensure opencode backup dropdown is populated if (el.opencodeBackup) { populateOpencodeBackupOptions(state.opencodeBackupModel); } } catch (err) { setStatus(err.message, true); } } // OpenCode model form handler if (el.opencodeModelForm) { el.opencodeModelForm.addEventListener('submit', async (e) => { e.preventDefault(); const name = el.opencodeModelSelect.value; const label = el.opencodeModelLabel.value.trim(); const icon = el.opencodeModelIcon ? el.opencodeModelIcon.value : ''; const tier = el.opencodeModelTier ? el.opencodeModelTier.value : 'free'; const supportsMedia = el.opencodeModelMedia ? el.opencodeModelMedia.checked : false; if (!name) { setOpencodeModelStatus('Please select a model.', true); return; } if (!label) { setOpencodeModelStatus('Display name is required.', true); return; } setOpencodeModelStatus('Saving...'); try { await api('/api/admin/models', { method: 'POST', body: JSON.stringify({ type: 'opencode', name, label, icon, tier, supportsMedia }), }); setOpencodeModelStatus('Saved'); el.opencodeModelSelect.value = ''; el.opencodeModelLabel.value = ''; await loadConfigured(); } catch (err) { setOpencodeModelStatus(err.message, true); } }); } // Reload OpenCode models button if (el.reloadOpencodeModels) { el.reloadOpencodeModels.addEventListener('click', async () => { setOpencodeModelStatus('Reloading models...'); await loadAvailable(); populateOpencodeModelSelect(); setOpencodeModelStatus('Models reloaded'); setTimeout(() => setOpencodeModelStatus(''), 1500); }); } // Public model form handler if (el.publicModelForm) { el.publicModelForm.addEventListener('submit', async (e) => { e.preventDefault(); const label = el.publicModelLabel.value.trim(); const icon = el.publicModelIcon ? el.publicModelIcon.value : ''; const tier = el.publicModelTier ? el.publicModelTier.value : 'free'; const supportsMedia = el.publicModelMedia ? el.publicModelMedia.checked : false; if (!label) { setPublicModelStatus('Display name is required.', true); return; } setPublicModelStatus('Saving...'); try { await api('/api/admin/models', { method: 'POST', body: JSON.stringify({ type: 'public', label, icon, tier, supportsMedia }), }); setPublicModelStatus('Saved'); el.publicModelLabel.value = ''; await loadConfigured(); } catch (err) { setPublicModelStatus(err.message, true); } }); } if (el.modelForm) { el.modelForm.addEventListener('submit', async (e) => { e.preventDefault(); const model = el.availableModels.value; const label = el.displayLabel.value.trim(); const icon = el.iconSelect.value; const tier = el.modelTier ? el.modelTier.value : 'free'; if (!model) { setStatus('Pick a model to add.', true); return; } if (!label) { setStatus('Add a display name.', true); return; } const providers = parseProviderOrderInput(el.providerOrder ? el.providerOrder.value : '', model); const supportsMedia = el.supportsMedia ? el.supportsMedia.checked : false; setStatus('Saving...'); try { await api('/api/admin/models', { method: 'POST', body: JSON.stringify({ model, label, icon, providers, tier, supportsMedia }), }); setStatus('Saved'); await loadConfigured(); } catch (err) { setStatus(err.message, true); } }); } if (el.orForm) { el.orForm.addEventListener('submit', async (e) => { e.preventDefault(); const primaryModel = el.orPrimary.value.trim(); const backupModel1 = el.orBackup1.value.trim(); const backupModel2 = el.orBackup2.value.trim(); const backupModel3 = el.orBackup3.value.trim(); if (!primaryModel) { setOrStatus('Primary model is required.', true); return; } setOrStatus('Saving...'); try { await api('/api/admin/openrouter-settings', { method: 'POST', body: JSON.stringify({ primaryModel, backupModel1, backupModel2, backupModel3 }), }); setOrStatus('Saved'); setTimeout(() => setOrStatus(''), 3000); } catch (err) { setOrStatus(err.message, true); } }); } if (el.mistralForm) { el.mistralForm.addEventListener('submit', async (e) => { e.preventDefault(); const primaryModel = el.mistralPrimary.value.trim(); const backupModel1 = el.mistralBackup1.value.trim(); const backupModel2 = el.mistralBackup2.value.trim(); const backupModel3 = el.mistralBackup3.value.trim(); if (!primaryModel) { setMistralStatus('Primary model is required.', true); return; } setMistralStatus('Saving...'); try { await api('/api/admin/mistral-settings', { method: 'POST', body: JSON.stringify({ primaryModel, backupModel1, backupModel2, backupModel3 }), }); setMistralStatus('Saved'); setTimeout(() => setMistralStatus(''), 3000); } catch (err) { setMistralStatus(err.message, true); } }); } if (el.planProviderForm) { el.planProviderForm.addEventListener('submit', async (e) => { e.preventDefault(); const provider = el.planProvider.value.trim(); if (!provider || !PLANNING_PROVIDERS.includes(provider)) { setPlanProviderStatus('Invalid provider selected.', true); return; } setPlanProviderStatus('Saving...'); try { await api('/api/admin/plan-settings', { method: 'POST', body: JSON.stringify({ provider }), }); setPlanProviderStatus('Saved'); setTimeout(() => setPlanProviderStatus(''), 3000); } catch (err) { setPlanProviderStatus(err.message, true); } }); } if (el.addPlanRow) { el.addPlanRow.addEventListener('click', async () => { const current = normalizePlanChainLocal(state.planSettings?.planningChain || []); const next = [...current, { provider: state.planSettings?.provider || 'openrouter', model: '' }]; await persistPlanChain(next); }); } if (el.providerLimitForm) { el.providerLimitForm.addEventListener('submit', async (e) => { e.preventDefault(); const provider = el.limitProvider.value; const scope = el.limitScope.value; const payload = { provider, scope, model: (pageType === 'plan' && el.limitModelInput) ? el.limitModelInput.value.trim() : el.limitModel.value.trim(), tokensPerMinute: Number(el.limitTpm.value || 0), tokensPerHour: Number(el.limitTph.value || 0), tokensPerDay: Number(el.limitTpd.value || 0), requestsPerMinute: Number(el.limitRpm.value || 0), requestsPerHour: Number(el.limitRph.value || 0), requestsPerDay: Number(el.limitRpd.value || 0), }; setProviderLimitStatus('Saving...'); try { await api('/api/admin/provider-limits', { method: 'POST', body: JSON.stringify(payload) }); setProviderLimitStatus('Saved'); await loadProviderLimits(); setTimeout(() => setProviderLimitStatus(''), 3000); } catch (err) { setProviderLimitStatus(err.message, true); } }); } if (el.limitProvider) { el.limitProvider.addEventListener('change', () => { populateLimitForm(el.limitProvider.value, el.limitScope ? el.limitScope.value : 'provider'); }); } if (el.limitScope) { el.limitScope.addEventListener('change', () => { populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope.value); }); } if (el.limitModel) { el.limitModel.addEventListener('change', () => { populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider'); }); } if (el.availableModels) { el.availableModels.addEventListener('change', () => { const selected = state.available.find((m) => (m.name || m.id || m) === el.availableModels.value); if (selected && !el.displayLabel.value) el.displayLabel.value = selected.label || selected.name || ''; }); } if (el.reloadAvailable) { el.reloadAvailable.addEventListener('click', async () => { setStatus('Refreshing available models...'); await loadAvailable(); setStatus(''); }); } if (el.refresh) { el.refresh.addEventListener('click', async () => { setStatus('Refreshing...'); await init(); setStatus(''); }); } // Plan tokens save button if (el.savePlanTokens) { el.savePlanTokens.addEventListener('click', async () => { await savePlanTokens(); }); } // Token rates save button if (el.saveTokenRates) { el.saveTokenRates.addEventListener('click', async () => { await saveTokenRates(); }); } // Cancel all messages button if (el.cancelAllMessages) { el.cancelAllMessages.addEventListener('click', async () => { const confirmed = window.confirm('Are you sure you want to cancel ALL running and queued messages? This action cannot be undone.'); if (!confirmed) return; el.cancelAllMessages.disabled = true; if (el.cancelMessagesStatus) el.cancelMessagesStatus.textContent = 'Cancelling...'; try { const data = await api('/api/admin/cancel-messages', { method: 'POST' }); if (el.cancelMessagesStatus) { el.cancelMessagesStatus.textContent = `Cancelled ${data.totalCancelled} messages (${data.runningCancelled} running, ${data.queuedCancelled} queued) across ${data.sessionsAffected} sessions`; el.cancelMessagesStatus.style.color = 'var(--accent)'; } setTimeout(() => { if (el.cancelMessagesStatus) { el.cancelMessagesStatus.textContent = ''; el.cancelMessagesStatus.style.color = 'inherit'; } }, 5000); } catch (err) { if (el.cancelMessagesStatus) { el.cancelMessagesStatus.textContent = err.message || 'Failed to cancel messages'; el.cancelMessagesStatus.style.color = 'var(--danger)'; } } finally { el.cancelAllMessages.disabled = false; } }); } if (el.externalTestingRun) { el.externalTestingRun.addEventListener('click', async () => { el.externalTestingRun.disabled = true; setExternalTestingStatus('Running self-test...'); try { const data = await api('/api/admin/external-testing-self-test', { method: 'POST' }); renderExternalTestingOutput(data.result || null); setExternalTestingStatus(data.result && data.result.ok ? 'Self-test passed.' : 'Self-test failed.', !data.result || !data.result.ok); } catch (err) { setExternalTestingStatus(err.message || 'Self-test failed.', true); } finally { el.externalTestingRun.disabled = false; } }); } // Ollama Test button handler if (el.ollamaTestRun) { el.ollamaTestRun.addEventListener('click', async () => { el.ollamaTestRun.disabled = true; setOllamaTestStatus('Running Ollama test...'); if (el.ollamaTestOutput) el.ollamaTestOutput.innerHTML = ''; try { const data = await api('/api/admin/ollama-test', { method: 'POST' }); renderOllamaTestOutput(data); if (data.ok) { setOllamaTestStatus(`Test passed! Response time: ${data.duration}ms`); } else { setOllamaTestStatus(`Test failed: ${data.error?.message || 'Unknown error'}`, true); } } catch (err) { setOllamaTestStatus(err.message || 'Test failed', true); if (el.ollamaTestOutput) { el.ollamaTestOutput.innerHTML = `
Error: ${err.message || 'Request failed'}
`; } } finally { el.ollamaTestRun.disabled = false; } }); } if (el.logout) { el.logout.addEventListener('click', async () => { await api('/api/admin/logout', { method: 'POST' }).catch(() => { }); window.location.href = '/admin/login'; }); } // Mobile sidebar toggle const menuToggle = document.getElementById('menu-toggle'); const closeSidebar = document.getElementById('close-sidebar'); const sidebar = document.querySelector('.sidebar'); const sidebarOverlay = document.querySelector('.sidebar-overlay'); if (menuToggle && sidebar) { menuToggle.addEventListener('click', () => { sidebar.classList.toggle('active'); if (sidebarOverlay) { sidebarOverlay.classList.toggle('active'); } document.body.classList.toggle('sidebar-open'); }); } if (closeSidebar && sidebar) { closeSidebar.addEventListener('click', () => { sidebar.classList.remove('active'); if (sidebarOverlay) { sidebarOverlay.classList.remove('active'); } document.body.classList.remove('sidebar-open'); }); } // Close sidebar when clicking on overlay if (sidebarOverlay && sidebar) { sidebarOverlay.addEventListener('click', () => { sidebar.classList.remove('active'); sidebarOverlay.classList.remove('active'); document.body.classList.remove('sidebar-open'); }); } // Close sidebar when clicking outside on mobile document.addEventListener('click', (e) => { if (sidebar && sidebar.classList.contains('active')) { if (!sidebar.contains(e.target) && (!menuToggle || !menuToggle.contains(e.target))) { sidebar.classList.remove('active'); if (sidebarOverlay) { sidebarOverlay.classList.remove('active'); } document.body.classList.remove('sidebar-open'); } } }); // Highlight active link in sidebar try { const navLinks = document.querySelectorAll('.sidebar-section a'); navLinks.forEach((a) => { const href = a.getAttribute('href'); const current = window.location.pathname; const isMatch = href === current || (href === '/admin/build' && current === '/admin'); if (isMatch) { a.classList.add('active'); a.setAttribute('aria-current', 'page'); } }); } catch (err) { } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();