diff --git a/chat/public/admin.html b/chat/public/admin.html index 2fa0759..26506fb 100644 --- a/chat/public/admin.html +++ b/chat/public/admin.html @@ -67,116 +67,180 @@ - -
-
-

OpenCode Models

-
Backend
-
-

Add models from OpenCode. When rate limits are reached, the system automatically falls back to the next model in the order below.

- - -
- - -
+
+ +
+
+

Add Public-Facing Model

+
Public
+
+

These models are displayed to users in the builder dropdown. They all share the same unified provider fallback chain.

+ -
- -
- - -
-
- - - -
-

OpenCode Models Order

-
0
-
-

Arrange the order below. #1 is the primary model. When it hits rate limits, the system falls back to #2, then #3, and so on.

-
-
- - -
-
-

Public Models

-
User-Facing
-
-

These models are displayed to users in the builder dropdown. They are completely separate from OpenCode models.

- - -
-
- -
-
- -
- -
- -
-
-
+ +
+ +
+
+ +
- -
-

Public Models List

-
0
-
-

These are shown to users. Use arrows to reorder.

-
+ +
+
+

Provider Model Chain

+
Backend
+
+

This is the unified fallback chain used for ALL models. When rate limits are reached or errors occur, the system automatically falls back to the next provider in this chain.

+
+ + +
+ +
+
+
+
+
+ + +
+
+

Unified Provider Chain Order

+
0
+
+

Arrange the order of providers below. The system will try each provider in order when rate limits are reached or errors occur. The first provider is the primary.

+
+
+ +
+ + + +
+
+

System Actions

+
Admin
+
+

Emergency controls for system management.

+
+ +
+
+
+
+ +
+
+

Icon Library

+
Step 0
+
+

Upload icon files to /chat/public/assets and pick them here. PNG, JPG, SVG, and WEBP are supported.

+
+
+
+ +
+
+

OpenCode Ultimate Backup Model

+
Fallback
+
+

Configure the ultimate fallback model that will be used when all providers fail. This is the last-resort backup for reliability.

+
+ +
+ +
+
+
-

Auto Model for Hobby/Free Plan

@@ -197,7 +261,6 @@
-
@@ -267,31 +330,17 @@
- -
-
-
-

System Actions

-
Admin
-
-

Emergency controls for system management.

-
- -
-
-
- -
-
-

Icon Library

-
Assets
-
-

Upload icon files to /chat/public/assets. PNG, JPG, SVG, and WEBP are supported.

-
-
+ +
+
+

Public-Facing Models

+
0
+
+

These models are displayed to users in the builder. All models use the unified provider chain above for fallback.

+
- - +
+ - \ No newline at end of file + diff --git a/chat/public/admin.js b/chat/public/admin.js index 871a5e6..70bee9e 100644 --- a/chat/public/admin.js +++ b/chat/public/admin.js @@ -1,21 +1,28 @@ (() => { + 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); - - // State structure const state = { + available: [], + configured: [], opencodeModels: [], // Models from OpenCode (order determines fallback) - publicModels: [], // User-facing models (completely separate) + publicModels: [], // Public-facing models (completely separate) icons: [], - availableOpencodeModels: [], // Loaded from OpenCode + accounts: [], + affiliates: [], + withdrawals: [], planSettings: { provider: 'openrouter', freePlanModel: '', planningChain: [] }, providerLimits: {}, providerUsage: [], + opencodeBackupModel: '', + providerOptions: [], + providerModels: {}, + tokenRates: {}, }; - // Element references const el = { - // OpenCode Models + // OpenCode Models (new structure) opencodeModelForm: document.getElementById('opencode-model-form'), opencodeModelSelect: document.getElementById('opencode-model-select'), opencodeModelLabel: document.getElementById('opencode-model-label'), @@ -26,8 +33,7 @@ reloadOpencodeModels: document.getElementById('reload-opencode-models'), opencodeModelsList: document.getElementById('opencode-models-list'), opencodeModelsCount: document.getElementById('opencode-models-count'), - - // Public Models + // Public Models (new structure) publicModelForm: document.getElementById('public-model-form'), publicModelName: document.getElementById('public-model-name'), publicModelLabel: document.getElementById('public-model-label'), @@ -37,566 +43,2657 @@ publicModelStatus: document.getElementById('public-model-status'), publicModelsList: document.getElementById('public-models-list'), publicModelsCount: document.getElementById('public-models-count'), - - // Auto Model Form + // 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'), autoModelForm: document.getElementById('auto-model-form'), autoModelSelect: document.getElementById('auto-model-select'), autoModelStatus: document.getElementById('auto-model-status'), - - // Provider Limits Form + planProviderForm: document.getElementById('plan-provider-form'), + planProvider: document.getElementById('plan-provider'), + freePlanModel: document.getElementById('free-plan-model'), + 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'), - - // Other - iconList: document.getElementById('icon-list'), - adminRefresh: document.getElementById('admin-refresh'), - adminLogout: document.getElementById('admin-logout'), + 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'), + opencodeBackupForm: document.getElementById('opencode-backup-form'), + opencodeBackup: document.getElementById('opencode-backup'), + opencodeBackupStatus: document.getElementById('opencode-backup-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'), }; + console.log('Element check - opencodeBackupForm:', el.opencodeBackupForm); + console.log('Element check - opencodeBackup:', el.opencodeBackup); + console.log('Element check - opencodeBackupStatus:', el.opencodeBackupStatus); - // Helper functions - function setStatus(el, msg, isError = false) { - if (!el) return; - el.textContent = msg || ''; - el.style.color = isError ? 'var(--danger)' : 'inherit'; + 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; } - async function api(url, options = {}) { - const res = await fetch(url, { - headers: { 'Content-Type': 'application/json' }, + 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 setAutoModelStatus(msg, isError = false) { + if (!el.autoModelStatus) return; + el.autoModelStatus.textContent = msg || ''; + el.autoModelStatus.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 setProviderChainStatus(msg, isError = false) { + if (!el.providerChainStatus) return; + el.providerChainStatus.textContent = msg || ''; + el.providerChainStatus.style.color = isError ? 'var(--danger)' : 'inherit'; + } + + function setOpencodeBackupStatus(msg, isError = false) { + if (!el.opencodeBackupStatus) return; + el.opencodeBackupStatus.textContent = msg || ''; + el.opencodeBackupStatus.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 data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Request failed'); + 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; } - // Load available models from OpenCode - async function loadAvailableOpencodeModels() { - try { - const data = await api('/api/admin/available-models'); - state.availableOpencodeModels = data.models || []; - renderOpencodeModelSelect(); - } catch (err) { - console.error('Failed to load OpenCode models:', err); - } + 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 })); } - // Render OpenCode model dropdown - function renderOpencodeModelSelect() { - if (!el.opencodeModelSelect) return; - el.opencodeModelSelect.innerHTML = ''; - - if (!state.availableOpencodeModels.length) { + function renderAvailable() { + if (!el.availableModels) return; + el.availableModels.innerHTML = ''; + if (!state.available.length) { const opt = document.createElement('option'); opt.value = ''; - opt.textContent = 'No models available'; - el.opencodeModelSelect.appendChild(opt); + opt.textContent = 'No models discovered'; + opt.disabled = true; + opt.selected = true; + el.availableModels.appendChild(opt); return; } - - state.availableOpencodeModels.forEach((m) => { + 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); + el.availableModels.appendChild(opt); }); - } - - // Render icons in selects - function renderIconOptions(selectEl) { - if (!selectEl) return; - const currentValue = selectEl.value; - selectEl.innerHTML = ''; - state.icons.forEach((iconPath) => { - const opt = document.createElement('option'); - opt.value = iconPath; - opt.textContent = iconPath.replace('/assets/', ''); - selectEl.appendChild(opt); - }); - selectEl.value = currentValue; - } - - // Load icons - async function loadIcons() { - try { - const data = await api('/api/admin/icons'); - state.icons = data.icons || []; - renderIconOptions(el.opencodeModelIcon); - renderIconOptions(el.publicModelIcon); - renderIconLibrary(); - } catch (err) { - console.error('Failed to load icons:', err); + if (!el.displayLabel.value) { + const first = state.available[0]; + if (first) el.displayLabel.value = first.label || first.name || ''; } } - // Render icon library - function renderIconLibrary() { - if (!el.iconList) return; - el.iconList.innerHTML = ''; - - if (!state.icons.length) { - el.iconList.innerHTML = '
No icons uploaded yet. Add files to /chat/public/assets
'; - return; - } - - state.icons.forEach((iconPath) => { - const row = document.createElement('div'); - row.className = 'admin-row'; - row.innerHTML = ` -
- - ${iconPath.replace('/assets/', '')} -
- `; - el.iconList.appendChild(row); - }); - } - - // Load all model data - async function loadModels() { - try { - const data = await api('/api/admin/models'); - state.opencodeModels = data.opencodeModels || []; - state.publicModels = data.publicModels || []; - renderOpencodeModels(); - renderPublicModels(); - } catch (err) { - console.error('Failed to load models:', err); - } - } - - // Render OpenCode Models list - function renderOpencodeModels() { - if (!el.opencodeModelsList) return; - el.opencodeModelsList.innerHTML = ''; - - if (el.opencodeModelsCount) { - el.opencodeModelsCount.textContent = state.opencodeModels.length.toString(); - } - - if (!state.opencodeModels.length) { - el.opencodeModelsList.innerHTML = '
No OpenCode models added yet.
'; - return; - } - - state.opencodeModels.forEach((m, idx) => { - const row = document.createElement('div'); - row.className = 'provider-row slim'; - row.innerHTML = ` -
-
- #${idx + 1} - ${m.icon ? `` : ''} - ${m.label || m.name} - ${m.name} - ${(m.tier || 'free').toUpperCase()} (${m.multiplier || 1}x) - ${m.supportsMedia ? 'Media' : ''} -
-
- - - -
-
- `; - - // Add event listeners - row.querySelector('.move-up')?.addEventListener('click', () => moveOpencodeModel(idx, -1)); - row.querySelector('.move-down')?.addEventListener('click', () => moveOpencodeModel(idx, 1)); - row.querySelector('.delete-btn')?.addEventListener('click', () => deleteModel(m.id, 'opencode')); - - el.opencodeModelsList.appendChild(row); - }); - } - - // Render Public Models - function renderPublicModels() { - if (!el.publicModelsList) return; - el.publicModelsList.innerHTML = ''; - - if (el.publicModelsCount) { - el.publicModelsCount.textContent = state.publicModels.length.toString(); - } - - if (!state.publicModels.length) { - el.publicModelsList.innerHTML = '
No public models added yet.
'; - return; - } - - state.publicModels.forEach((m, idx) => { - const row = document.createElement('div'); - row.className = 'provider-row slim'; - row.innerHTML = ` -
-
- #${idx + 1} - ${m.icon ? `` : ''} - ${m.label || m.name} - ${m.name} - ${(m.tier || 'free').toUpperCase()} (${m.multiplier || 1}x) - ${m.supportsMedia ? 'Media' : ''} -
-
- - - -
-
- `; - - row.querySelector('.move-up')?.addEventListener('click', () => movePublicModel(idx, -1)); - row.querySelector('.move-down')?.addEventListener('click', () => movePublicModel(idx, 1)); - row.querySelector('.delete-btn')?.addEventListener('click', () => deleteModel(m.id, 'public')); - - el.publicModelsList.appendChild(row); - }); - } - - // Move OpenCode Model - async function moveOpencodeModel(fromIdx, direction) { - const toIdx = fromIdx + direction; - if (toIdx < 0 || toIdx >= state.opencodeModels.length) return; - - const newOrder = [...state.opencodeModels]; - const [item] = newOrder.splice(fromIdx, 1); - newOrder.splice(toIdx, 0, item); - - try { - await api('/api/admin/models/reorder', { - method: 'POST', - body: JSON.stringify({ type: 'opencode', models: newOrder }), + 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); }); - state.opencodeModels = newOrder; - renderOpencodeModels(); - } catch (err) { - console.error('Failed to reorder:', err); } - } - - // Move Public Model - async function movePublicModel(fromIdx, direction) { - const toIdx = fromIdx + direction; - if (toIdx < 0 || toIdx >= state.publicModels.length) return; - const newOrder = [...state.publicModels]; - const [item] = newOrder.splice(fromIdx, 1); - newOrder.splice(toIdx, 0, item); - - try { - await api('/api/admin/models/reorder', { - method: 'POST', - body: JSON.stringify({ type: 'public', models: newOrder }), + // Populate new 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); }); - state.publicModels = newOrder; - renderPublicModels(); - } catch (err) { - console.error('Failed to reorder:', err); } - } - // Delete Model - async function deleteModel(id, type) { - try { - await api(`/api/admin/models/${id}?type=${type}`, { method: 'DELETE' }); - await loadModels(); - } catch (err) { - console.error('Failed to delete:', err); - } - } - - // Form Handlers - if (el.opencodeModelForm) { - el.opencodeModelForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const model = el.opencodeModelSelect.value; - const label = el.opencodeModelLabel.value.trim(); - - if (!model || !label) { - setStatus(el.opencodeModelStatus, 'Model and display name are required', true); + 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); + }); + } + } + + // Simplified renderConfigured for new publicModels structure + function renderConfigured() { + if (!el.configuredList) return; + el.configuredList.innerHTML = ''; + + // Use publicModels if available, otherwise fall back to configured + const modelsToRender = state.publicModels.length > 0 ? state.publicModels : state.configured; + + if (el.configuredCount) el.configuredCount.textContent = modelsToRender.length.toString(); + if (!modelsToRender.length) { + const empty = document.createElement('div'); + empty.className = 'muted'; + empty.textContent = 'No public-facing models configured yet.'; + el.configuredList.appendChild(empty); + return; + } + + modelsToRender.forEach((m) => { + const row = document.createElement('div'); + row.className = 'provider-row slim'; + + const header = document.createElement('div'); + header.className = 'provider-row-header'; - try { - await api('/api/admin/models', { - method: 'POST', - body: JSON.stringify({ - type: 'opencode', - name: model, - label, - tier: el.opencodeModelTier?.value || 'free', - icon: el.opencodeModelIcon?.value || '', - supportsMedia: el.opencodeModelMedia?.checked || false, - }), + const info = document.createElement('div'); + info.className = 'model-chip'; + + 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); + } + + // Show chain info badge + const chainBadge = document.createElement('span'); + chainBadge.className = 'pill'; + chainBadge.style.background = 'var(--primary)'; + chainBadge.textContent = `Uses unified chain (${state.providerChain.length} providers)`; + info.appendChild(chainBadge); + + header.appendChild(info); + + const headerActions = document.createElement('div'); + headerActions.className = 'provider-row-actions'; + + 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}`, { 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); }); - setStatus(el.opencodeModelStatus, 'Added'); - el.opencodeModelLabel.value = ''; - await loadModels(); - } catch (err) { - setStatus(el.opencodeModelStatus, err.message, true); + 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 = 'Supports image uploads'; + 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/multiplier'; + 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.configuredList.appendChild(row); + }); + } + + 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); } }); } - if (el.reloadOpencodeModels) { - el.reloadOpencodeModels.addEventListener('click', async () => { - setStatus(el.opencodeModelStatus, 'Loading...'); - await loadAvailableOpencodeModels(); - setStatus(el.opencodeModelStatus, 'Loaded'); - setTimeout(() => setStatus(el.opencodeModelStatus, ''), 1500); + 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(); + + const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string' + ? state.planSettings.freePlanModel + : (el.autoModelSelect ? el.autoModelSelect.value : ''); + populateAutoModelOptions(selectedAutoModel); + + 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.publicModels = data.publicModels || []; + state.providerChain = data.providerChain || []; + state.configured = data.models || []; // Legacy support + renderConfigured(); + renderProviderChain(); + const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string' + ? state.planSettings.freePlanModel + : (el.autoModelSelect ? el.autoModelSelect.value : ''); + populateAutoModelOptions(selectedAutoModel); + populateFreePlanModelOptions(selectedAutoModel); + syncAvailableModelDatalist(); + if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter'); + } + + 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 ?? ''; + if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || ''; + } + + 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 || []; + state.opencodeBackupModel = data.opencodeBackupModel || ''; + renderProviderOptions(); + populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider'); + renderProviderUsage(); + if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || ''; + populateOpencodeBackupOptions(state.opencodeBackupModel); + // 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.autoModelForm && !el.planPriorityList) return; + try { + const data = await api('/api/admin/plan-settings'); + state.planSettings = { + provider: 'openrouter', + freePlanModel: '', + planningChain: [], + ...(data || {}), + }; + if (el.planProvider) el.planProvider.value = state.planSettings.provider || 'openrouter'; + populateAutoModelOptions(state.planSettings.freePlanModel || ''); + populateFreePlanModelOptions(state.planSettings.freePlanModel || ''); + renderPlanPriority(); + } catch (err) { + if (el.planProviderForm) setPlanProviderStatus(err.message, true); + if (el.autoModelForm) setAutoModelStatus(err.message, true); + if (el.planPriorityList) setPlanChainStatus(err.message, true); + } + } + + function populateAutoModelOptions(selectedValue) { + if (!el.autoModelSelect) return; + + const normalizeTier = (tier) => { + const normalized = String(tier || 'free').trim().toLowerCase(); + return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free'; + }; + + const configured = Array.isArray(state.configured) ? state.configured : []; + const configuredByName = new Map(); + configured.forEach((m) => { + const name = (m && (m.name || m.id)) ? String(m.name || m.id).trim() : ''; + if (name) configuredByName.set(name, m); + }); + + const current = typeof selectedValue === 'string' ? selectedValue : el.autoModelSelect.value; + + el.autoModelSelect.innerHTML = ''; + const auto = document.createElement('option'); + auto.value = ''; + auto.textContent = 'Auto (first free model)'; + el.autoModelSelect.appendChild(auto); + + const freeModels = configured + .filter((m) => normalizeTier(m.tier) === 'free') + .map((m) => ({ + name: (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '', + label: (m && (m.label || m.name || m.id)) ? String(m.label || m.name || m.id).trim() : '', + })) + .filter((m) => m.name); + + const freeGroup = document.createElement('optgroup'); + freeGroup.label = 'Free-tier models'; + + const freeNames = new Set(); + freeModels + .sort((a, b) => a.label.localeCompare(b.label)) + .forEach((m) => { + freeNames.add(m.name); + const opt = document.createElement('option'); + opt.value = m.name; + opt.textContent = m.label || m.name; + freeGroup.appendChild(opt); + }); + + const discoveredNames = getAvailableModelNames() + .map((name) => String(name || '').trim()) + .filter(Boolean) + .filter((name, idx, arr) => arr.indexOf(name) === idx) + .filter((name) => !freeNames.has(name)); + + const discoveredGroup = document.createElement('optgroup'); + discoveredGroup.label = 'Other discovered models'; + + discoveredNames + .sort((a, b) => a.localeCompare(b)) + .forEach((name) => { + const configuredModel = configuredByName.get(name); + const tier = configuredModel ? normalizeTier(configuredModel.tier) : null; + const opt = document.createElement('option'); + opt.value = name; + if (!configuredModel) { + opt.textContent = `${name} (unpublished)`; + } else { + opt.textContent = `${name} (${tier.toUpperCase()})`; + if (tier !== 'free') opt.disabled = true; + } + discoveredGroup.appendChild(opt); + }); + + const hasFree = freeGroup.children.length > 0; + const hasDiscovered = discoveredGroup.children.length > 0; + + if (hasFree) { + el.autoModelSelect.appendChild(freeGroup); + } + + if (hasDiscovered) { + el.autoModelSelect.appendChild(discoveredGroup); + } + + if (!hasFree && !hasDiscovered) { + const note = document.createElement('option'); + note.value = '__none__'; + note.textContent = '(No models discovered yet)'; + note.disabled = true; + el.autoModelSelect.appendChild(note); + } + + const currentName = (current || '').trim(); + if (currentName && !Array.from(el.autoModelSelect.options).some((opt) => opt.value === currentName)) { + const orphan = document.createElement('option'); + orphan.value = currentName; + orphan.textContent = `${currentName} (current selection)`; + el.autoModelSelect.appendChild(orphan); + } + + el.autoModelSelect.value = currentName; + } + + function populateFreePlanModelOptions(selectedValue) { + if (!el.freePlanModel) return; + const current = selectedValue || el.freePlanModel.value; + el.freePlanModel.innerHTML = ''; + const auto = document.createElement('option'); + auto.value = ''; + auto.textContent = 'Auto (use default)'; + el.freePlanModel.appendChild(auto); + (state.configured || []).forEach((m) => { + const opt = document.createElement('option'); + opt.value = m.name || m.id || ''; + opt.textContent = m.label || m.name || m.id || ''; + el.freePlanModel.appendChild(opt); + }); + if (current !== undefined && current !== null) { + el.freePlanModel.value = current; + } + } + + function populateOpencodeBackupOptions(selectedValue) { + console.log('populateOpencodeBackupOptions called with:', selectedValue); + if (!el.opencodeBackup) { + console.log('el.opencodeBackup is null, returning early'); + return; + } + console.log('el.opencodeBackup found, populating...'); + const current = selectedValue || el.opencodeBackup.value; + el.opencodeBackup.innerHTML = ''; + + const allModels = new Set(); + (state.available || []).forEach((m) => { + const name = m.name || m.id || m; + if (name) allModels.add(name); + }); + (state.configured || []).forEach((m) => { + if (m.name) allModels.add(m.name); + }); + Object.values(state.providerModels || {}).forEach((arr) => { + (arr || []).forEach((name) => { if (name) allModels.add(name); }); + }); + + console.log('Found models:', Array.from(allModels)); + const sorted = Array.from(allModels).filter(Boolean).sort((a, b) => a.localeCompare(b)); + + const none = document.createElement('option'); + none.value = ''; + none.textContent = 'None (no backup)'; + el.opencodeBackup.appendChild(none); + + sorted.forEach((name) => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + el.opencodeBackup.appendChild(opt); + }); + + if (current) el.opencodeBackup.value = current; + console.log('Dropdown populated with', sorted.length + 1, 'options'); + } + + 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.configuredList ? 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)); + // 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); + } + } + + // New public model form handler if (el.publicModelForm) { el.publicModelForm.addEventListener('submit', async (e) => { e.preventDefault(); const name = el.publicModelName.value.trim(); 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 (!name || !label) { - setStatus(el.publicModelStatus, 'Model ID and display name are required', true); + if (!name) { + setPublicModelStatus('Model ID is required.', true); + return; + } + if (!label) { + setPublicModelStatus('Display name is required.', true); return; } + setPublicModelStatus('Saving...'); try { await api('/api/admin/models', { method: 'POST', - body: JSON.stringify({ - type: 'public', - name, - label, - tier: el.publicModelTier?.value || 'free', - icon: el.publicModelIcon?.value || '', - supportsMedia: el.publicModelMedia?.checked || false, + body: JSON.stringify({ + type: 'publicModel', + name, + label, + icon, + tier, + supportsMedia }), }); - setStatus(el.publicModelStatus, 'Added'); + setPublicModelStatus('Saved'); el.publicModelName.value = ''; el.publicModelLabel.value = ''; - await loadModels(); + await loadConfigured(); } catch (err) { - setStatus(el.publicModelStatus, err.message, true); + setPublicModelStatus(err.message, true); } }); } - // Initialize - // Load plan settings - async function loadPlanSettings() { - try { - const data = await api('/api/admin/plan-settings'); - state.planSettings = data || { provider: 'openrouter', freePlanModel: '', planningChain: [] }; - populateAutoModelSelect(); - if (el.autoModelSelect && state.planSettings.freePlanModel) { - el.autoModelSelect.value = state.planSettings.freePlanModel; + // New provider chain form handler + if (el.providerChainForm) { + el.providerChainForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const provider = el.chainProvider.value; + const model = el.chainModel.value.trim(); + + if (!model) { + setProviderChainStatus('Model name is required.', true); + return; + } + + setProviderChainStatus('Adding to chain...'); + try { + const newChain = [...state.providerChain, { provider, model }]; + await api('/api/admin/models', { + method: 'POST', + body: JSON.stringify({ + type: 'providerChain', + chain: newChain + }), + }); + setProviderChainStatus('Added to chain'); + el.chainModel.value = ''; + await loadConfigured(); + } catch (err) { + setProviderChainStatus(err.message, true); } - } catch (err) { - console.error('Failed to load plan settings:', err); - } - } - - // Populate auto model select dropdown - function populateAutoModelSelect() { - if (!el.autoModelSelect) return; - const currentValue = el.autoModelSelect.value; - el.autoModelSelect.innerHTML = ''; - - state.opencodeModels.forEach((m) => { - const opt = document.createElement('option'); - opt.value = m.name; - opt.textContent = `${m.label || m.name} (${m.name})`; - el.autoModelSelect.appendChild(opt); - }); - - el.autoModelSelect.value = currentValue; - } - - // Load provider limits - async function loadProviderLimits() { - try { - const data = await api('/api/admin/provider-limits'); - state.providerLimits = data.limits || {}; - state.providerUsage = data.usage || []; - renderProviderUsage(); - } catch (err) { - console.error('Failed to load provider limits:', err); - } - } - - // Render provider usage - function renderProviderUsage() { - if (!el.providerUsage) return; - el.providerUsage.innerHTML = ''; - - if (!state.providerUsage.length) { - el.providerUsage.innerHTML = '
No usage data available.
'; - return; - } - - state.providerUsage.forEach((usage) => { - const row = document.createElement('div'); - row.className = 'admin-row'; - row.innerHTML = ` -
- ${usage.provider} - ${usage.tokens || 0} tokens / ${usage.requests || 0} requests -
- `; - el.providerUsage.appendChild(row); }); } - // Update limit model options based on provider selection - function updateLimitModelOptions() { - if (!el.limitModel || !el.limitProvider) return; - const provider = el.limitProvider.value; - const currentValue = el.limitModel.value; - - el.limitModel.innerHTML = ''; - - // Add models from opencodeModels that match this provider - state.opencodeModels.forEach((m) => { - if (m.name && m.name.includes('/')) { - const modelProvider = m.name.split('/')[0]; - if (modelProvider === provider) { - const opt = document.createElement('option'); - opt.value = m.name; - opt.textContent = m.label || m.name; - el.limitModel.appendChild(opt); + 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.opencodeBackupForm) { + el.opencodeBackupForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const opencodeBackupModel = el.opencodeBackup ? el.opencodeBackup.value.trim() : ''; + + setOpencodeBackupStatus('Saving...'); + try { + const res = await api('/api/admin/provider-limits', { + method: 'POST', + body: JSON.stringify({ provider: 'opencode', scope: 'provider', model: '', tokensPerMinute: '', tokensPerDay: '', requestsPerMinute: '', requestsPerDay: '', opencodeBackupModel }), + }); + // update local state and refresh dropdowns + state.opencodeBackupModel = res.opencodeBackupModel || opencodeBackupModel || ''; + populateOpencodeBackupOptions(state.opencodeBackupModel); + setOpencodeBackupStatus('Saved'); + setTimeout(() => setOpencodeBackupStatus(''), 3000); + } catch (err) { + setOpencodeBackupStatus(err.message, true); + } + }); + } + + if (el.autoModelForm) { + el.autoModelForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const freePlanModel = el.autoModelSelect ? el.autoModelSelect.value.trim() : ''; + + setAutoModelStatus('Saving...'); + try { + await api('/api/admin/plan-settings', { + method: 'POST', + body: JSON.stringify({ freePlanModel }), + }); + setAutoModelStatus('Saved! Free plan users will use this model.'); + setTimeout(() => setAutoModelStatus(''), 3000); + } catch (err) { + setAutoModelStatus(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), + opencodeBackupModel: el.limitBackup ? el.limitBackup.value.trim() : '', + }; + 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; } }); - - el.limitModel.value = currentValue; } - async function init() { - await loadIcons(); - await loadAvailableOpencodeModels(); - await loadModels(); - await loadPlanSettings(); - await loadProviderLimits(); + 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; + } + }); } - if (el.adminRefresh) { - el.adminRefresh.addEventListener('click', init); + // 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.adminLogout) { - el.adminLogout.addEventListener('click', async () => { - await api('/api/admin/logout', { method: 'POST' }); + if (el.logout) { + el.logout.addEventListener('click', async () => { + await api('/api/admin/logout', { method: 'POST' }).catch(() => { }); window.location.href = '/admin/login'; }); } - if (el.cancelAllMessages) { - el.cancelAllMessages.addEventListener('click', async () => { - if (!confirm('Are you sure you want to cancel all running and queued messages?')) return; - try { - await api('/api/admin/cancel-all-messages', { method: 'POST' }); - setStatus(el.cancelMessagesStatus, 'All messages cancelled'); - } catch (err) { - setStatus(el.cancelMessagesStatus, err.message, true); + // 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'); }); } - // Auto Model Form Handler - if (el.autoModelForm) { - el.autoModelForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const selectedModel = el.autoModelSelect.value; - - try { - await api('/api/admin/plan-settings', { - method: 'POST', - body: JSON.stringify({ - ...state.planSettings, - freePlanModel: selectedModel, - }), - }); - setStatus(el.autoModelStatus, 'Saved'); - setTimeout(() => setStatus(el.autoModelStatus, ''), 1500); - } catch (err) { - setStatus(el.autoModelStatus, err.message, true); + if (closeSidebar && sidebar) { + closeSidebar.addEventListener('click', () => { + sidebar.classList.remove('active'); + if (sidebarOverlay) { + sidebarOverlay.classList.remove('active'); } + document.body.classList.remove('sidebar-open'); }); } - // Provider Limit Form Handler - if (el.providerLimitForm) { - // Update model options when provider changes - el.limitProvider?.addEventListener('change', () => { - updateLimitModelOptions(); - // Load existing limits for this provider if any - const provider = el.limitProvider.value; - const scope = el.limitScope.value; - const model = el.limitModel.value; - const limits = state.providerLimits[provider]; - if (limits) { - const target = scope === 'model' && model ? (limits.perModel?.[model] || {}) : limits; - 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 || ''; - } - }); - - // Update form when scope changes - el.limitScope?.addEventListener('change', () => { - if (el.limitModel) { - el.limitModel.disabled = el.limitScope.value !== 'model'; - if (el.limitScope.value !== 'model') el.limitModel.value = ''; - } - }); - - el.providerLimitForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const provider = el.limitProvider?.value; - const scope = el.limitScope?.value; - const model = el.limitModel?.value; - - const limits = { - tokensPerMinute: parseInt(el.limitTpm?.value) || 0, - tokensPerHour: parseInt(el.limitTph?.value) || 0, - tokensPerDay: parseInt(el.limitTpd?.value) || 0, - requestsPerMinute: parseInt(el.limitRpm?.value) || 0, - requestsPerHour: parseInt(el.limitRph?.value) || 0, - requestsPerDay: parseInt(el.limitRpd?.value) || 0, - }; - - try { - const payload = { - provider, - scope, - model: scope === 'model' ? model : null, - limits, - }; - await api('/api/admin/provider-limits', { - method: 'POST', - body: JSON.stringify(payload), - }); - setStatus(el.providerLimitStatus, 'Saved'); - await loadProviderLimits(); - setTimeout(() => setStatus(el.providerLimitStatus, ''), 1500); - } catch (err) { - setStatus(el.providerLimitStatus, err.message, true); - } + // 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'); }); } - init(); -})(); \ No newline at end of file + // 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(); + } +})();