diff --git a/chat/public/admin.html b/chat/public/admin.html index e30a843..826a314 100644 --- a/chat/public/admin.html +++ b/chat/public/admin.html @@ -67,57 +67,71 @@ -
- -
-
-

Add Provider Model

-
OpenCode
-
-

Add models from OpenCode that will be available to users. These models use the unified provider chain for fallback.

-
+ +
+
+

OpenCode Models

+
Backend
+
+

Add models from OpenCode. These models process requests and use the OpenCode Chain below for fallback when rate limits are reached.

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

Add to Provider Chain

-
Fallback
-
-

Add providers to the unified fallback chain. When rate limits are reached or errors occur, the system automatically falls back to the next provider in this chain.

-
+ +
+

OpenCode Models List

+
0
+
+

Arrange the order below. This controls which model is primary for OpenCode requests.

+
+
+ + +
+
+

OpenCode Chain

+
Fallback
+
+

When rate limits are reached or errors occur, the system falls back through this chain of providers.

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

Chain Order

+
0
+
+

The system tries providers in this order. Use arrows to reorder.

+
- +
-

Unified Provider Chain Order

-
0
+

Public Models

+
User-Facing
-

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.

-
-
- - -
-
-

Public-Facing Models

-
Public
-
-

These models are displayed to users in the builder dropdown. They are separate from the provider models and can be used to create curated model lists for users.

-
-
+

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.

+
- - -

System Actions

@@ -261,136 +234,18 @@
-

Icon Library

-
Step 0
+
Assets
-

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

+

Upload icon files to /chat/public/assets. 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

-
Free Plan
-
-

Select which model Hobby and Free plan users will automatically use. Paid plan users can select their own models.

-
- -
- -
-
-
-
- -
-
-
-

Provider Limits & Usage

-
Rate limits
-
-

Configure token/request limits per provider or per model and monitor current usage.

-
- - - - - - - - - -
- -
-
-
-
-
-
- - -
-
-

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 52ec4d2..d221356 100644 --- a/chat/public/admin.js +++ b/chat/public/admin.js @@ -1,44 +1,41 @@ (() => { - const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', 'opencode', 'cohere']; + const DEFAULT_PROVIDERS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', '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); + + // Clean state structure const state = { - available: [], // Models available from OpenCode - configured: [], // Legacy - provider models from OpenCode - providerModels: [], // Provider models (from OpenCode) - providerChain: [], // Unified fallback chain - publicModels: [], // Public-facing models (completely separate) + opencodeModels: [], // Models from OpenCode + opencodeChain: [], // Fallback chain for OpenCode + publicModels: [], // User-facing models (completely separate) icons: [], - accounts: [], - affiliates: [], - withdrawals: [], - planSettings: { provider: 'openrouter', freePlanModel: '', planningChain: [] }, - providerLimits: {}, - providerUsage: [], - opencodeBackupModel: '', - providerOptions: [], - tokenRates: {}, + availableOpencodeModels: [], // Loaded from OpenCode }; + // Element references - clean structure matching the HTML const el = { - // Provider Models (with OpenCode integration) - providerModelForm: document.getElementById('provider-model-form'), - availableModels: document.getElementById('available-models'), - providerModelLabel: document.getElementById('provider-model-label'), - providerModelTier: document.getElementById('provider-model-tier'), - providerModelIcon: document.getElementById('provider-model-icon'), - providerModelMedia: document.getElementById('provider-model-media'), - providerModelStatus: document.getElementById('provider-model-status'), - reloadAvailable: document.getElementById('reload-available'), - // Provider Chain - providerChainForm: document.getElementById('provider-chain-form'), + // OpenCode Models + opencodeModelForm: document.getElementById('opencode-model-form'), + opencodeModelSelect: document.getElementById('opencode-model-select'), + opencodeModelLabel: document.getElementById('opencode-model-label'), + opencodeModelTier: document.getElementById('opencode-model-tier'), + opencodeModelIcon: document.getElementById('opencode-model-icon'), + opencodeModelMedia: document.getElementById('opencode-model-media'), + opencodeModelStatus: document.getElementById('opencode-model-status'), + reloadOpencodeModels: document.getElementById('reload-opencode-models'), + opencodeModelsList: document.getElementById('opencode-models-list'), + opencodeModelsCount: document.getElementById('opencode-models-count'), + + // OpenCode Chain + chainForm: document.getElementById('chain-form'), chainProvider: document.getElementById('chain-provider'), chainModel: document.getElementById('chain-model'), - providerChainStatus: document.getElementById('provider-chain-status'), - providerChainList: document.getElementById('provider-chain-list'), - providerChainCount: document.getElementById('provider-chain-count'), - // Public-Facing Models (completely separate) + chainStatus: document.getElementById('chain-status'), + chainList: document.getElementById('chain-list'), + chainCount: document.getElementById('chain-count'), + + // Public Models publicModelForm: document.getElementById('public-model-form'), publicModelName: document.getElementById('public-model-name'), publicModelLabel: document.getElementById('public-model-label'), @@ -48,3072 +45,475 @@ publicModelStatus: document.getElementById('public-model-status'), publicModelsList: document.getElementById('public-models-list'), publicModelsCount: document.getElementById('public-models-count'), - // Legacy elements - displayLabel: document.getElementById('display-label'), - modelTier: document.getElementById('model-tier'), - iconSelect: document.getElementById('icon-select'), + + // Other 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'), - 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'), - 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 + adminRefresh: document.getElementById('admin-refresh'), + adminLogout: document.getElementById('admin-logout'), 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); - 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; + // Helper functions + function setStatus(el, msg, isError = false) { + if (!el) return; + el.textContent = msg || ''; + el.style.color = isError ? 'var(--danger)' : 'inherit'; } - 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 setProviderModelStatus(msg, isError = false) { - if (!el.providerModelStatus) return; - el.providerModelStatus.textContent = msg || ''; - el.providerModelStatus.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 || {}) }, + async function api(url, options = {}) { + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, ...options, }); - const text = await res.text(); - const data = text ? JSON.parse(text) : {}; - if (res.status === 401) { - window.location.href = '/admin/login'; - throw new Error('Unauthorized'); - } - if (!res.ok) throw new Error(data.error || res.statusText); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Request failed'); return data; } - function parseProviderOrderInput(raw, fallbackModel) { - const input = (raw || '').trim(); - if (!input) return []; - const parts = input.split(',').map((p) => p.trim()).filter(Boolean); - return parts.map((part, idx) => { - const segments = part.split(':').map((s) => s.trim()); - const provider = segments[0]; - const model = segments[1] || fallbackModel || provider; - if (!provider) return null; - return { provider, model, primary: idx === 0 }; - }).filter(Boolean).map((p, idx) => ({ ...p, primary: idx === 0 })); + // 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 renderAvailable() { - if (!el.availableModels) return; - el.availableModels.innerHTML = ''; - if (!state.available.length) { + // Render OpenCode model dropdown + function renderOpencodeModelSelect() { + if (!el.opencodeModelSelect) return; + el.opencodeModelSelect.innerHTML = ''; + + if (!state.availableOpencodeModels.length) { const opt = document.createElement('option'); opt.value = ''; - opt.textContent = 'No models discovered'; - opt.disabled = true; - opt.selected = true; - el.availableModels.appendChild(opt); + opt.textContent = 'No models available'; + el.opencodeModelSelect.appendChild(opt); return; } - state.available.forEach((m) => { + + state.availableOpencodeModels.forEach((m) => { const opt = document.createElement('option'); opt.value = m.name || m.id || m; opt.textContent = m.label || m.name || m.id || m; - el.availableModels.appendChild(opt); + el.opencodeModelSelect.appendChild(opt); }); - if (!el.displayLabel.value) { - const first = state.available[0]; - if (first) el.displayLabel.value = first.label || first.name || ''; + } + + // 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); } } - 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); - }); + // 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; } - // Populate provider model icon select - if (el.providerModelIcon) { - el.providerModelIcon.innerHTML = ''; - const none = document.createElement('option'); - none.value = ''; - none.textContent = 'No icon'; - el.providerModelIcon.appendChild(none); - state.icons.forEach((iconPath) => { - const opt = document.createElement('option'); - opt.value = iconPath; - opt.textContent = iconPath.replace('/assets/', ''); - el.providerModelIcon.appendChild(opt); - }); - } - - // Populate public model icon select - if (el.publicModelIcon) { - el.publicModelIcon.innerHTML = ''; - const none = document.createElement('option'); - none.value = ''; - none.textContent = 'No icon'; - el.publicModelIcon.appendChild(none); - state.icons.forEach((iconPath) => { - const opt = document.createElement('option'); - opt.value = iconPath; - opt.textContent = iconPath.replace('/assets/', ''); - el.publicModelIcon.appendChild(opt); - }); - } + state.icons.forEach((iconPath) => { + const row = document.createElement('div'); + row.className = 'admin-row'; + row.innerHTML = ` +
+ + ${iconPath.replace('/assets/', '')} +
+ `; + el.iconList.appendChild(row); + }); + } - 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); - }); + // Load all model data + async function loadModels() { + try { + const data = await api('/api/admin/models'); + state.opencodeModels = data.opencodeModels || []; + state.opencodeChain = data.opencodeChain || []; + state.publicModels = data.publicModels || []; + renderOpencodeModels(); + renderOpencodeChain(); + renderPublicModels(); + } catch (err) { + console.error('Failed to load models:', err); } } - // Render Provider Models (from OpenCode) - function renderProviderModels() { - if (!el.configuredList) return; - el.configuredList.innerHTML = ''; + // Render OpenCode Models list + function renderOpencodeModels() { + if (!el.opencodeModelsList) return; + el.opencodeModelsList.innerHTML = ''; - const modelsToRender = state.providerModels; + if (el.opencodeModelsCount) { + el.opencodeModelsCount.textContent = state.opencodeModels.length.toString(); + } - if (el.configuredCount) el.configuredCount.textContent = modelsToRender.length.toString(); - if (!modelsToRender.length) { - const empty = document.createElement('div'); - empty.className = 'muted'; - empty.textContent = 'No provider models configured yet. Add models from OpenCode above.'; - el.configuredList.appendChild(empty); + if (!state.opencodeModels.length) { + el.opencodeModelsList.innerHTML = '
No OpenCode models added yet.
'; return; } - modelsToRender.forEach((m, idx) => { + state.opencodeModels.forEach((m, idx) => { const row = document.createElement('div'); row.className = 'provider-row slim'; - - const header = document.createElement('div'); - header.className = 'provider-row-header'; + row.innerHTML = ` +
+
+ #${idx + 1} + ${m.icon ? `` : ''} + ${m.label || m.name} + ${m.name} + ${(m.tier || 'free').toUpperCase()} (${m.multiplier || 1}x) + ${m.supportsMedia ? 'Media' : ''} +
+
+ + + +
+
+ `; - const info = document.createElement('div'); - info.className = 'model-chip'; + // 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')); - // Order number badge - const orderBadge = document.createElement('span'); - orderBadge.className = 'pill'; - orderBadge.textContent = `#${idx + 1}`; - orderBadge.style.background = idx === 0 ? 'var(--shopify-green)' : 'var(--primary)'; - orderBadge.style.fontWeight = '700'; - info.appendChild(orderBadge); - - 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'; - - // Up button - const upBtn = document.createElement('button'); - upBtn.className = 'ghost'; - upBtn.textContent = '↑'; - upBtn.title = 'Move up'; - upBtn.disabled = idx === 0; - upBtn.addEventListener('click', async () => { - if (idx === 0) return; - const next = [...modelsToRender]; - const [item] = next.splice(idx, 1); - next.splice(idx - 1, 0, item); - await persistProviderModelsOrder(next); - }); - headerActions.appendChild(upBtn); - - // Down button - const downBtn = document.createElement('button'); - downBtn.className = 'ghost'; - downBtn.textContent = '↓'; - downBtn.title = 'Move down'; - downBtn.disabled = idx === modelsToRender.length - 1; - downBtn.addEventListener('click', async () => { - if (idx === modelsToRender.length - 1) return; - const next = [...modelsToRender]; - const [item] = next.splice(idx, 1); - next.splice(idx + 1, 0, item); - await persistProviderModelsOrder(next); - }); - headerActions.appendChild(downBtn); - - const delBtn = document.createElement('button'); - delBtn.className = 'ghost'; - delBtn.textContent = 'Delete'; - delBtn.addEventListener('click', async () => { - delBtn.disabled = true; - try { - await api(`/api/admin/models/${m.id}`, { method: 'DELETE' }); - await loadConfigured(); - } catch (err) { - setProviderModelStatus(err.message, true); - } - delBtn.disabled = false; - }); - headerActions.appendChild(delBtn); - - // Inline icon editor button - const editIconBtn = document.createElement('button'); - editIconBtn.className = 'ghost'; - editIconBtn.textContent = 'Edit icon'; - editIconBtn.addEventListener('click', () => { - let editor = header.querySelector('.icon-editor'); - if (editor) return editor.remove(); - editor = document.createElement('div'); - editor.className = 'icon-editor'; - - const sel = document.createElement('select'); - const none = document.createElement('option'); - none.value = ''; - none.textContent = 'No icon'; - sel.appendChild(none); - (state.icons || []).forEach((iconPath) => { - const o = document.createElement('option'); - o.value = iconPath; - o.textContent = iconPath.replace('/assets/', ''); - sel.appendChild(o); - }); - sel.value = m.icon || ''; - editor.appendChild(sel); - - const saveBtn = document.createElement('button'); - saveBtn.className = 'primary'; - saveBtn.textContent = 'Save'; - saveBtn.addEventListener('click', async () => { - saveBtn.disabled = true; - try { - await persistProviderModelChanges(m.id, { icon: sel.value }); - } catch (err) { setProviderModelStatus(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 persistProviderModelChanges(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); + el.opencodeModelsList.appendChild(row); }); } - // Render Public Models (completely separate from OpenCode) + // Render OpenCode Chain + function renderOpencodeChain() { + if (!el.chainList) return; + el.chainList.innerHTML = ''; + + if (el.chainCount) { + el.chainCount.textContent = state.opencodeChain.length.toString(); + } + + if (!state.opencodeChain.length) { + el.chainList.innerHTML = '
No fallback chain configured. Add providers above.
'; + return; + } + + state.opencodeChain.forEach((entry, idx) => { + const row = document.createElement('div'); + row.className = 'provider-row slim'; + row.innerHTML = ` +
+
+ ${idx === 0 ? 'Primary' : `#${idx + 1}`} + ${entry.provider} + ${entry.model} +
+
+ + + +
+
+ `; + + row.querySelector('.move-up')?.addEventListener('click', () => moveChainItem(idx, -1)); + row.querySelector('.move-down')?.addEventListener('click', () => moveChainItem(idx, 1)); + row.querySelector('.delete-btn')?.addEventListener('click', () => removeChainItem(idx)); + + el.chainList.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 (el.publicModelsCount) { + el.publicModelsCount.textContent = state.publicModels.length.toString(); + } if (!state.publicModels.length) { - const empty = document.createElement('div'); - empty.className = 'muted'; - empty.textContent = 'No public-facing models configured yet.'; - el.publicModelsList.appendChild(empty); + el.publicModelsList.innerHTML = '
No public models added yet.
'; return; } state.publicModels.forEach((m, idx) => { const row = document.createElement('div'); row.className = 'provider-row slim'; - - const header = document.createElement('div'); - header.className = 'provider-row-header'; + row.innerHTML = ` +
+
+ #${idx + 1} + ${m.icon ? `` : ''} + ${m.label || m.name} + ${m.name} + ${(m.tier || 'free').toUpperCase()} (${m.multiplier || 1}x) + ${m.supportsMedia ? 'Media' : ''} +
+
+ + + +
+
+ `; - const info = document.createElement('div'); - info.className = 'model-chip'; + 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')); - // Order number badge - const orderBadge = document.createElement('span'); - orderBadge.className = 'pill'; - orderBadge.textContent = `#${idx + 1}`; - orderBadge.style.background = idx === 0 ? 'var(--shopify-green)' : 'var(--primary)'; - orderBadge.style.fontWeight = '700'; - info.appendChild(orderBadge); - - if (m.icon) { - const img = document.createElement('img'); - img.src = m.icon; - img.alt = ''; - info.appendChild(img); - } - - const label = document.createElement('span'); - label.textContent = m.label || m.name; - info.appendChild(label); - - const namePill = document.createElement('span'); - namePill.className = 'pill'; - namePill.textContent = m.name; - info.appendChild(namePill); - - const tierMeta = document.createElement('span'); - tierMeta.className = 'pill'; - const tierName = (m.tier || 'free').toUpperCase(); - const multiplier = m.tier === 'pro' ? 3 : (m.tier === 'plus' ? 2 : 1); - tierMeta.textContent = `${tierName} (${multiplier}x)`; - info.appendChild(tierMeta); - - if (m.supportsMedia) { - const mediaBadge = document.createElement('span'); - mediaBadge.className = 'pill'; - mediaBadge.style.background = 'var(--shopify-green)'; - mediaBadge.textContent = 'Media'; - info.appendChild(mediaBadge); - } - - header.appendChild(info); - - const headerActions = document.createElement('div'); - headerActions.className = 'provider-row-actions'; - - // Up button - const upBtn = document.createElement('button'); - upBtn.className = 'ghost'; - upBtn.textContent = '↑'; - upBtn.title = 'Move up'; - upBtn.disabled = idx === 0; - upBtn.addEventListener('click', async () => { - if (idx === 0) return; - const next = [...state.publicModels]; - const [item] = next.splice(idx, 1); - next.splice(idx - 1, 0, item); - await persistPublicModelsOrder(next); - }); - headerActions.appendChild(upBtn); - - // Down button - const downBtn = document.createElement('button'); - downBtn.className = 'ghost'; - downBtn.textContent = '↓'; - downBtn.title = 'Move down'; - downBtn.disabled = idx === state.publicModels.length - 1; - downBtn.addEventListener('click', async () => { - if (idx === state.publicModels.length - 1) return; - const next = [...state.publicModels]; - const [item] = next.splice(idx, 1); - next.splice(idx + 1, 0, item); - await persistPublicModelsOrder(next); - }); - headerActions.appendChild(downBtn); - - const delBtn = document.createElement('button'); - delBtn.className = 'ghost'; - delBtn.textContent = 'Delete'; - delBtn.addEventListener('click', async () => { - delBtn.disabled = true; - try { - await api(`/api/admin/models/${m.id}`, { method: 'DELETE' }); - await loadConfigured(); - } catch (err) { - setPublicModelStatus(err.message, true); - } - delBtn.disabled = false; - }); - headerActions.appendChild(delBtn); - - // Inline icon editor button - const editIconBtn = document.createElement('button'); - editIconBtn.className = 'ghost'; - editIconBtn.textContent = 'Edit icon'; - editIconBtn.addEventListener('click', () => { - let editor = header.querySelector('.icon-editor'); - if (editor) return editor.remove(); - editor = document.createElement('div'); - editor.className = 'icon-editor'; - - const sel = document.createElement('select'); - const none = document.createElement('option'); - none.value = ''; - none.textContent = 'No icon'; - sel.appendChild(none); - (state.icons || []).forEach((iconPath) => { - const o = document.createElement('option'); - o.value = iconPath; - o.textContent = iconPath.replace('/assets/', ''); - sel.appendChild(o); - }); - sel.value = m.icon || ''; - editor.appendChild(sel); - - const saveBtn = document.createElement('button'); - saveBtn.className = 'primary'; - saveBtn.textContent = 'Save'; - saveBtn.addEventListener('click', async () => { - saveBtn.disabled = true; - try { - await persistPublicModelChanges(m.id, { icon: sel.value }); - } catch (err) { setPublicModelStatus(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) { setPublicModelStatus(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) { setPublicModelStatus(err.message, true); } - saveBtn.disabled = false; - }); - editor.appendChild(saveBtn); - - const cancelBtn = document.createElement('button'); - cancelBtn.className = 'ghost'; - cancelBtn.textContent = 'Cancel'; - cancelBtn.addEventListener('click', () => editor.remove()); - editor.appendChild(cancelBtn); - - headerActions.appendChild(editor); - }); - headerActions.appendChild(editTierBtn); - - header.appendChild(headerActions); - row.appendChild(header); el.publicModelsList.appendChild(row); }); } - async function persistProviderModelChanges(modelId, changes) { - setProviderModelStatus('Saving...'); - const model = state.providerModels.find((m) => m.id === modelId); - if (!model) { - setProviderModelStatus('Model not found', true); - return; - } + // Move OpenCode Model + async function moveOpencodeModel(fromIdx, direction) { + const toIdx = fromIdx + direction; + if (toIdx < 0 || toIdx >= state.opencodeModels.length) return; - const payload = { - type: 'providerModel', - 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, - }; + const newOrder = [...state.opencodeModels]; + const [item] = newOrder.splice(fromIdx, 1); + newOrder.splice(toIdx, 0, item); try { - const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) }); - const idx = state.providerModels.findIndex((m) => m.id === modelId); - if (idx >= 0) state.providerModels[idx] = { ...state.providerModels[idx], ...data.providerModel }; - renderProviderModels(); - setProviderModelStatus('Saved'); - setTimeout(() => setProviderModelStatus(''), 1500); - } catch (err) { - setProviderModelStatus(err.message, true); - } - } - - async function persistProviderModelsOrder(orderedModels) { - setProviderModelStatus('Saving order...'); - try { - const res = await api('/api/admin/models/reorder', { + await api('/api/admin/models/reorder', { method: 'POST', - body: JSON.stringify({ type: 'providerModels', models: orderedModels }), + body: JSON.stringify({ type: 'opencode', models: newOrder }), }); - state.providerModels = res.providerModels || orderedModels; - renderProviderModels(); - setProviderModelStatus('Order saved'); - setTimeout(() => setProviderModelStatus(''), 1500); + state.opencodeModels = newOrder; + renderOpencodeModels(); } catch (err) { - setProviderModelStatus(err.message, true); + console.error('Failed to reorder:', err); } } - async function persistPublicModelChanges(modelId, changes) { - setPublicModelStatus('Saving...'); - const model = state.publicModels.find((m) => m.id === modelId); - if (!model) { - setPublicModelStatus('Model not found', true); - return; - } + // Move Chain Item + async function moveChainItem(fromIdx, direction) { + const toIdx = fromIdx + direction; + if (toIdx < 0 || toIdx >= state.opencodeChain.length) 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, - }; + const newOrder = [...state.opencodeChain]; + const [item] = newOrder.splice(fromIdx, 1); + newOrder.splice(toIdx, 0, item); 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); - } - } - - async function persistPublicModelsOrder(orderedModels) { - setPublicModelStatus('Saving order...'); - try { - // Use the reorder endpoint to save the new order - const res = await api('/api/admin/models/reorder', { + await api('/api/admin/chain/reorder', { method: 'POST', - body: JSON.stringify({ type: 'publicModels', models: orderedModels }), + body: JSON.stringify({ chain: newOrder }), }); - // Update local state with new order from server - state.publicModels = res.publicModels || orderedModels; + state.opencodeChain = newOrder; + renderOpencodeChain(); + } catch (err) { + console.error('Failed to reorder chain:', 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 }), + }); + state.publicModels = newOrder; renderPublicModels(); - setPublicModelStatus('Order saved'); - setTimeout(() => setPublicModelStatus(''), 1500); } catch (err) { - setPublicModelStatus(err.message, true); + console.error('Failed to reorder:', err); } } - // 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...'); + // Delete Model + async function deleteModel(id, type) { 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); + await api(`/api/admin/models/${id}?type=${type}`, { method: 'DELETE' }); + await loadModels(); } catch (err) { - setProviderChainStatus(err.message, true); + console.error('Failed to delete:', err); } } - 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...'); + // Remove Chain Item + async function removeChainItem(idx) { + const newChain = state.opencodeChain.filter((_, i) => i !== idx); try { - const payload = { planningChain: nextChain }; - const res = await api('/api/admin/plan-settings', { method: 'POST', body: JSON.stringify(payload) }); - const normalized = normalizePlanChainLocal(res.settings?.planningChain || nextChain); - state.planSettings = { ...state.planSettings, ...(res.settings || {}), planningChain: normalized }; - renderPlanPriority(); - setPlanChainStatus('Saved'); - setTimeout(() => setPlanChainStatus(''), 1500); - } catch (err) { - setPlanChainStatus(err.message, true); - } - } - - function renderPlanPriority() { - if (!el.planPriorityList) return; - const chain = normalizePlanChainLocal(state.planSettings?.planningChain || []); - el.planPriorityList.innerHTML = ''; - if (!chain.length) { - const empty = document.createElement('div'); - empty.className = 'muted'; - empty.textContent = 'No planning models configured. Add one to enable planning fallbacks.'; - el.planPriorityList.appendChild(empty); - } - - chain.forEach((entry, idx) => { - const row = document.createElement('div'); - row.className = 'provider-row slim'; - - const header = document.createElement('div'); - header.className = 'provider-row-header'; - const info = document.createElement('div'); - info.className = 'model-chip'; - const order = document.createElement('span'); - order.className = 'pill'; - order.textContent = `Priority #${idx + 1}`; - info.appendChild(order); - - const providerSelect = document.createElement('select'); - PLANNING_PROVIDERS.forEach((provider) => { - const opt = document.createElement('option'); - opt.value = provider; - opt.textContent = provider; - providerSelect.appendChild(opt); - }); - providerSelect.value = entry.provider; - providerSelect.addEventListener('change', () => { - const next = normalizePlanChainLocal(chain.map((c, i) => (i === idx ? { ...c, provider: providerSelect.value } : c))); - persistPlanChain(next); - }); - info.appendChild(providerSelect); - - const modelInput = document.createElement('input'); - modelInput.type = 'text'; - modelInput.placeholder = 'Model id (supports OpenRouter, Mistral, Google, Groq, NVIDIA)'; - // Prefer showing the exact user input (`raw`) when available, otherwise show - // the normalized `model` value. - modelInput.value = entry.raw || entry.model; - modelInput.setAttribute('list', ensureAvailableDatalist().id); - modelInput.addEventListener('blur', () => { - const val = modelInput.value.trim(); - const next = normalizePlanChainLocal(chain.map((c, i) => (i === idx ? { ...c, model: val, raw: val } : c))); - persistPlanChain(next); - }); - info.appendChild(modelInput); - - const limitPill = document.createElement('span'); - limitPill.className = 'pill'; - limitPill.textContent = planLimitSummary(entry.provider, entry.model); - info.appendChild(limitPill); - - header.appendChild(info); - - const actions = document.createElement('div'); - actions.className = 'provider-row-actions'; - const upBtn = document.createElement('button'); - upBtn.className = 'ghost'; - upBtn.textContent = '↑'; - upBtn.title = 'Move up'; - upBtn.disabled = idx === 0; - upBtn.addEventListener('click', () => { - const next = [...chain]; - const [item] = next.splice(idx, 1); - next.splice(Math.max(0, idx - 1), 0, item); - persistPlanChain(next); - }); - actions.appendChild(upBtn); - - const downBtn = document.createElement('button'); - downBtn.className = 'ghost'; - downBtn.textContent = '↓'; - downBtn.title = 'Move down'; - downBtn.disabled = idx === chain.length - 1; - downBtn.addEventListener('click', () => { - const next = [...chain]; - const [item] = next.splice(idx, 1); - next.splice(Math.min(chain.length, idx + 1), 0, item); - persistPlanChain(next); - }); - actions.appendChild(downBtn); - - const removeBtn = document.createElement('button'); - removeBtn.className = 'ghost'; - removeBtn.textContent = 'Remove'; - removeBtn.addEventListener('click', () => { - const next = chain.filter((_, i) => i !== idx); - persistPlanChain(next); - }); - actions.appendChild(removeBtn); - - header.appendChild(actions); - row.appendChild(header); - el.planPriorityList.appendChild(row); - }); - } - - function renderProviderUsage() { - if (!el.providerUsage) return; - el.providerUsage.innerHTML = ''; - if (!state.providerUsage.length) { - const empty = document.createElement('div'); - empty.className = 'muted'; - empty.textContent = 'No usage recorded yet.'; - el.providerUsage.appendChild(empty); - return; - } - - function createUsageRow(provider, label, limits, usage) { - const row = document.createElement('div'); - row.className = 'admin-row'; - const left = document.createElement('div'); - left.style.display = 'flex'; - left.style.flexDirection = 'column'; - left.style.minWidth = '200px'; - left.innerHTML = `${provider} ${label}`; - - const progress = document.createElement('div'); - progress.style.display = 'flex'; - progress.style.flexDirection = 'column'; - progress.style.gap = '6px'; - progress.style.flex = '1'; - - const rows = [ - ['Tokens (1m)', usage.tokensLastMinute || 0, limits.tokensPerMinute || 0], - ['Tokens (24h)', usage.tokensLastDay || 0, limits.tokensPerDay || 0], - ['Requests (1m)', usage.requestsLastMinute || 0, limits.requestsPerMinute || 0], - ['Requests (24h)', usage.requestsLastDay || 0, limits.requestsPerDay || 0], - ]; - - rows.forEach(([labelText, used, limit]) => { - const wrap = document.createElement('div'); - wrap.style.display = 'flex'; - wrap.style.flexDirection = 'column'; - const labelEl = document.createElement('div'); - labelEl.style.display = 'flex'; - labelEl.style.justifyContent = 'space-between'; - labelEl.style.fontSize = '12px'; - labelEl.innerHTML = `${labelText}${used}${limit > 0 ? ` / ${limit}` : ''}`; - wrap.appendChild(labelEl); - if (limit > 0) { - const barOuter = document.createElement('div'); - barOuter.style.background = 'var(--border)'; - barOuter.style.height = '6px'; - barOuter.style.borderRadius = '6px'; - const barInner = document.createElement('div'); - barInner.style.height = '6px'; - barInner.style.borderRadius = '6px'; - const pct = Math.min(100, parseFloat(((used / limit) * 100).toFixed(1))); - barInner.style.width = `${pct}%`; - barInner.style.background = pct > 90 ? 'var(--danger)' : 'var(--primary)'; - barOuter.appendChild(barInner); - wrap.appendChild(barOuter); - } - progress.appendChild(wrap); - }); - - row.appendChild(left); - row.appendChild(progress); - return row; - } - - state.providerUsage.forEach((entry) => { - const isPerModel = entry.scope === 'model'; - const perModelLimits = entry.perModelLimits || {}; - const perModelUsage = (entry.usage && entry.usage.perModel) || {}; - const modelNames = isPerModel ? [...new Set([...Object.keys(perModelLimits), ...Object.keys(perModelUsage)])] : []; - - // If per-model scope is enabled and we have models, show them individually - if (isPerModel && modelNames.length > 0) { - modelNames.forEach((modelName) => { - const limits = perModelLimits[modelName] || {}; - const usage = perModelUsage[modelName] || {}; - const row = createUsageRow(entry.provider, modelName, limits, usage); - el.providerUsage.appendChild(row); - }); - } else { - // Provider-level scope or no models yet - show aggregate - const limits = entry.limits || {}; - const usage = entry.usage || {}; - const row = createUsageRow(entry.provider, entry.scope, limits, usage); - el.providerUsage.appendChild(row); - } - }); - } - - function renderProviderOptions() { - if (!el.limitProvider) return; - const providersFromState = Array.isArray(state.providerOptions) && state.providerOptions.length - ? [...state.providerOptions] - : null; - let providers = providersFromState || Object.keys(state.providerLimits || {}); - const current = el.limitProvider.value; - el.limitProvider.innerHTML = ''; - if (!providers.length) providers = [...DEFAULT_PROVIDERS]; - providers.forEach((provider, idx) => { - const opt = document.createElement('option'); - opt.value = provider; - opt.textContent = provider; - if (provider === current || (!current && idx === 0)) opt.selected = true; - el.limitProvider.appendChild(opt); - }); - } - - function renderLimitModelOptions(provider) { - // If we're on the plan page, allow free-text model entry (admins can type any model id) - if (pageType === 'plan') { - if (!el.limitModelInput) return; - // show input and hide the select - if (el.limitModel) el.limitModel.style.display = 'none'; - el.limitModelInput.style.display = ''; - // Populate datalist with discovered models to help typing - syncAvailableModelDatalist(); - // set current value from configured per-model limit if present - const cfg = state.providerLimits && state.providerLimits[provider] ? state.providerLimits[provider] : {}; - const modelKey = el.limitModelInput.value || ''; - // Do not overwrite user's typing; keep existing value - if (!el.limitModelInput.value && cfg.perModel) { - // nothing to prefill unless there's exactly one per-model configured - const keys = Object.keys(cfg.perModel || {}); - if (keys.length === 1) el.limitModelInput.value = keys[0]; - } - return; - } - - if (!el.limitModel) return; - el.limitModel.style.display = ''; - if (el.limitModelInput) el.limitModelInput.style.display = 'none'; - const current = el.limitModel.value; - const modelsFromProvider = (state.providerModels && state.providerModels[provider]) ? state.providerModels[provider] : []; - const combined = new Set(modelsFromProvider); - getAvailableModelNames().forEach((m) => combined.add(m)); - const sorted = Array.from(combined).filter(Boolean).sort((a, b) => a.localeCompare(b)); - el.limitModel.innerHTML = ''; - const anyOpt = document.createElement('option'); - anyOpt.value = ''; - anyOpt.textContent = 'Any model'; - el.limitModel.appendChild(anyOpt); - sorted.forEach((model) => { - const opt = document.createElement('option'); - opt.value = model; - opt.textContent = model; - el.limitModel.appendChild(opt); - }); - if (current && sorted.includes(current)) el.limitModel.value = current; - } - - async function loadAvailable() { - const data = await api('/api/admin/available-models'); - state.available = data.models || []; - renderAvailable(); - syncAvailableModelDatalist(); - - 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 structure - separate provider models and public models - state.providerModels = data.providerModels || []; - state.publicModels = data.publicModels || []; - state.providerChain = data.providerChain || []; - state.configured = data.models || []; // Legacy support - renderProviderModels(); - renderPublicModels(); - 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', { + await api('/api/admin/chain', { method: 'POST', - body: JSON.stringify({ userId: acct.id, plan: normalized }) + body: JSON.stringify({ chain: newChain }), }); - setStatus('Plan updated successfully'); - await loadAccounts(); - setTimeout(() => setStatus(''), 3000); + state.opencodeChain = newChain; + renderOpencodeChain(); } catch (err) { - setStatus(err.message, true); + console.error('Failed to remove from chain:', err); } } - 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); - } - } - - // Provider model form handler (with OpenCode integration) - if (el.providerModelForm) { - el.providerModelForm.addEventListener('submit', async (e) => { + // Form Handlers + if (el.opencodeModelForm) { + el.opencodeModelForm.addEventListener('submit', async (e) => { e.preventDefault(); - const model = el.availableModels.value; - const label = el.providerModelLabel.value.trim(); - const icon = el.providerModelIcon ? el.providerModelIcon.value : ''; - const tier = el.providerModelTier ? el.providerModelTier.value : 'free'; - const supportsMedia = el.providerModelMedia ? el.providerModelMedia.checked : false; + const model = el.opencodeModelSelect.value; + const label = el.opencodeModelLabel.value.trim(); - if (!model) { - setProviderModelStatus('Please select a model from OpenCode.', true); - return; - } - if (!label) { - setProviderModelStatus('Display name is required.', true); + if (!model || !label) { + setStatus(el.opencodeModelStatus, 'Model and display name are required', true); return; } - setProviderModelStatus('Saving...'); try { await api('/api/admin/models', { method: 'POST', - body: JSON.stringify({ - type: 'providerModel', - name: model, - label, - icon, - tier, - supportsMedia + body: JSON.stringify({ + type: 'opencode', + name: model, + label, + tier: el.opencodeModelTier?.value || 'free', + icon: el.opencodeModelIcon?.value || '', + supportsMedia: el.opencodeModelMedia?.checked || false, }), }); - setProviderModelStatus('Saved'); - el.providerModelLabel.value = ''; - await loadConfigured(); + setStatus(el.opencodeModelStatus, 'Added'); + el.opencodeModelLabel.value = ''; + await loadModels(); } catch (err) { - setProviderModelStatus(err.message, true); + setStatus(el.opencodeModelStatus, err.message, true); } }); } - // Reload available models button - if (el.reloadAvailable) { - el.reloadAvailable.addEventListener('click', async () => { - setProviderModelStatus('Loading...'); - try { - await loadAvailable(); - setProviderModelStatus('Models loaded'); - setTimeout(() => setProviderModelStatus(''), 1500); - } catch (err) { - setProviderModelStatus(err.message, true); - } + if (el.reloadOpencodeModels) { + el.reloadOpencodeModels.addEventListener('click', async () => { + setStatus(el.opencodeModelStatus, 'Loading...'); + await loadAvailableOpencodeModels(); + setStatus(el.opencodeModelStatus, 'Loaded'); + setTimeout(() => setStatus(el.opencodeModelStatus, ''), 1500); }); } - // Public model form handler (completely separate from OpenCode) - 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) { - 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: 'publicModel', - name, - label, - icon, - tier, - supportsMedia - }), - }); - setPublicModelStatus('Saved'); - el.publicModelName.value = ''; - el.publicModelLabel.value = ''; - await loadConfigured(); - } catch (err) { - setPublicModelStatus(err.message, true); - } - }); - } - - // New provider chain form handler - if (el.providerChainForm) { - el.providerChainForm.addEventListener('submit', async (e) => { + if (el.chainForm) { + el.chainForm.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); + setStatus(el.chainStatus, 'Model ID is required', true); + return; + } + + const newChain = [...state.opencodeChain, { provider, model }]; + + try { + await api('/api/admin/chain', { + method: 'POST', + body: JSON.stringify({ chain: newChain }), + }); + setStatus(el.chainStatus, 'Added to chain'); + el.chainModel.value = ''; + await loadModels(); + } catch (err) { + setStatus(el.chainStatus, err.message, true); + } + }); + } + + if (el.publicModelForm) { + el.publicModelForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const name = el.publicModelName.value.trim(); + const label = el.publicModelLabel.value.trim(); + + if (!name || !label) { + setStatus(el.publicModelStatus, 'Model ID and display name are 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 + body: JSON.stringify({ + type: 'public', + name, + label, + tier: el.publicModelTier?.value || 'free', + icon: el.publicModelIcon?.value || '', + supportsMedia: el.publicModelMedia?.checked || false, }), }); - setProviderChainStatus('Added to chain'); - el.chainModel.value = ''; - await loadConfigured(); + setStatus(el.publicModelStatus, 'Added'); + el.publicModelName.value = ''; + el.publicModelLabel.value = ''; + await loadModels(); } catch (err) { - setProviderChainStatus(err.message, true); + setStatus(el.publicModelStatus, err.message, true); } }); } - if (el.modelForm) { - el.modelForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const model = el.availableModels.value; - const label = el.displayLabel.value.trim(); - const icon = el.iconSelect.value; - const tier = el.modelTier ? el.modelTier.value : 'free'; - if (!model) { - setStatus('Pick a model to add.', true); - return; - } - if (!label) { - setStatus('Add a display name.', true); - return; - } - const providers = parseProviderOrderInput(el.providerOrder ? el.providerOrder.value : '', model); - const supportsMedia = el.supportsMedia ? el.supportsMedia.checked : false; - setStatus('Saving...'); - try { - await api('/api/admin/models', { - method: 'POST', - body: JSON.stringify({ model, label, icon, providers, tier, supportsMedia }), - }); - setStatus('Saved'); - await loadConfigured(); - } catch (err) { - setStatus(err.message, true); - } - }); + // Initialize + async function init() { + await loadIcons(); + await loadAvailableOpencodeModels(); + await loadModels(); } - 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.adminRefresh) { + el.adminRefresh.addEventListener('click', init); } - 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; - } - }); - } - - if (el.externalTestingRun) { - el.externalTestingRun.addEventListener('click', async () => { - el.externalTestingRun.disabled = true; - setExternalTestingStatus('Running self-test...'); - try { - const data = await api('/api/admin/external-testing-self-test', { method: 'POST' }); - renderExternalTestingOutput(data.result || null); - setExternalTestingStatus(data.result && data.result.ok ? 'Self-test passed.' : 'Self-test failed.', !data.result || !data.result.ok); - } catch (err) { - setExternalTestingStatus(err.message || 'Self-test failed.', true); - } finally { - el.externalTestingRun.disabled = false; - } - }); - } - - // Ollama Test button handler - if (el.ollamaTestRun) { - el.ollamaTestRun.addEventListener('click', async () => { - el.ollamaTestRun.disabled = true; - setOllamaTestStatus('Running Ollama test...'); - if (el.ollamaTestOutput) el.ollamaTestOutput.innerHTML = ''; - - try { - const data = await api('/api/admin/ollama-test', { method: 'POST' }); - renderOllamaTestOutput(data); - - if (data.ok) { - setOllamaTestStatus(`Test passed! Response time: ${data.duration}ms`); - } else { - setOllamaTestStatus(`Test failed: ${data.error?.message || 'Unknown error'}`, true); - } - } catch (err) { - setOllamaTestStatus(err.message || 'Test failed', true); - if (el.ollamaTestOutput) { - el.ollamaTestOutput.innerHTML = `
Error: ${err.message || 'Request failed'}
`; - } - } finally { - el.ollamaTestRun.disabled = false; - } - }); - } - - if (el.logout) { - el.logout.addEventListener('click', async () => { - await api('/api/admin/logout', { method: 'POST' }).catch(() => { }); + if (el.adminLogout) { + el.adminLogout.addEventListener('click', async () => { + await api('/api/admin/logout', { method: 'POST' }); window.location.href = '/admin/login'; }); } - // Mobile sidebar toggle - const menuToggle = document.getElementById('menu-toggle'); - const closeSidebar = document.getElementById('close-sidebar'); - const sidebar = document.querySelector('.sidebar'); - const sidebarOverlay = document.querySelector('.sidebar-overlay'); - - if (menuToggle && sidebar) { - menuToggle.addEventListener('click', () => { - sidebar.classList.toggle('active'); - if (sidebarOverlay) { - sidebarOverlay.classList.toggle('active'); + 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); } - document.body.classList.toggle('sidebar-open'); }); } - if (closeSidebar && sidebar) { - closeSidebar.addEventListener('click', () => { - sidebar.classList.remove('active'); - if (sidebarOverlay) { - sidebarOverlay.classList.remove('active'); - } - document.body.classList.remove('sidebar-open'); - }); - } - - // Close sidebar when clicking on overlay - if (sidebarOverlay && sidebar) { - sidebarOverlay.addEventListener('click', () => { - sidebar.classList.remove('active'); - sidebarOverlay.classList.remove('active'); - document.body.classList.remove('sidebar-open'); - }); - } - - // Close sidebar when clicking outside on mobile - document.addEventListener('click', (e) => { - if (sidebar && sidebar.classList.contains('active')) { - if (!sidebar.contains(e.target) && (!menuToggle || !menuToggle.contains(e.target))) { - sidebar.classList.remove('active'); - if (sidebarOverlay) { - sidebarOverlay.classList.remove('active'); - } - document.body.classList.remove('sidebar-open'); - } - } - }); - - // Highlight active link in sidebar - try { - const navLinks = document.querySelectorAll('.sidebar-section a'); - navLinks.forEach((a) => { - const href = a.getAttribute('href'); - const current = window.location.pathname; - const isMatch = href === current || (href === '/admin/build' && current === '/admin'); - if (isMatch) { - a.classList.add('active'); - a.setAttribute('aria-current', 'page'); - } - }); - } catch (err) { } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); + init(); +})(); \ No newline at end of file diff --git a/chat/server.js b/chat/server.js index 6ddb72b..d4b86de 100644 --- a/chat/server.js +++ b/chat/server.js @@ -1524,10 +1524,10 @@ const MODELS_DEV_CACHE_TTL = 3600000; // 1 hour const adminSessions = new Map(); let adminModels = []; let adminModelIndex = new Map(); -// New unified model chain structure -let providerModels = []; // Provider models from OpenCode [{id, name, label, icon, tier, supportsMedia, multiplier}] +// Clean model structure +let opencodeModels = []; // Models from OpenCode [{id, name, label, icon, tier, supportsMedia, multiplier}] +let opencodeChain = []; // OpenCode fallback chain [{provider, model}] let publicModels = []; // Public-facing models (completely separate) [{id, name, label, icon, tier, supportsMedia, multiplier}] -let providerChain = []; // Unified fallback chain [{provider, model, primary}] let openrouterSettings = { primaryModel: OPENROUTER_MODEL_PRIMARY, backupModel1: OPENROUTER_MODEL_BACKUP_1, @@ -5757,21 +5757,21 @@ async function loadAdminModelStore() { try { await ensureStateFile(); await ensureAssetsDir(); - const raw = await fs.readFile(ADMIN_MODELS_FILE, 'utf8').catch(() => '[]'); - const parsed = JSON.parse(raw || '[]'); + const raw = await fs.readFile(ADMIN_MODELS_FILE, 'utf8').catch(() => '{}'); + const parsed = JSON.parse(raw || '{}'); - // Check if using new unified structure or old structure + // Load clean structure if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - // New unified structure - providerModels = Array.isArray(parsed.providerModels) ? parsed.providerModels : []; + // New clean structure + opencodeModels = Array.isArray(parsed.opencodeModels) ? parsed.opencodeModels : []; + opencodeChain = Array.isArray(parsed.opencodeChain) ? parsed.opencodeChain : []; publicModels = Array.isArray(parsed.publicModels) ? parsed.publicModels : []; - providerChain = Array.isArray(parsed.providerChain) ? parsed.providerChain : []; adminModels = Array.isArray(parsed.adminModels) ? parsed.adminModels : []; } else if (Array.isArray(parsed)) { - // Old array structure - migrate to new + // Old array structure - migrate adminModels = parsed; - // Create provider models from admin models (these are from OpenCode) - providerModels = parsed.map((m) => ({ + // Migrate old admin models to opencode models + opencodeModels = parsed.map((m) => ({ id: m.id || randomUUID(), name: m.name, label: m.label || m.name, @@ -5780,35 +5780,33 @@ async function loadAdminModelStore() { supportsMedia: m.supportsMedia ?? false, multiplier: getTierMultiplier(m.tier), })).filter((m) => !!m.name); - // Public models start empty (user can add these manually later) publicModels = []; - // Create unified provider chain from all unique providers + // Create chain from providers const allProviders = new Map(); parsed.forEach((m) => { const providers = Array.isArray(m.providers) && m.providers.length ? m.providers - : [{ provider: 'opencode', model: m.name, primary: true }]; + : [{ provider: 'opencode', model: m.name }]; providers.forEach((p) => { const key = `${p.provider}:${p.model || m.name}`; if (!allProviders.has(key)) { allProviders.set(key, { provider: normalizeProviderName(p.provider || 'opencode'), model: (p.model || m.name || '').trim() || m.name, - primary: allProviders.size === 0, }); } }); }); - providerChain = Array.from(allProviders.values()); + opencodeChain = Array.from(allProviders.values()); } else { - providerModels = []; + opencodeModels = []; + opencodeChain = []; publicModels = []; - providerChain = []; adminModels = []; } - // Ensure all provider models have required fields - providerModels = providerModels.map((m) => ({ + // Validate opencode models + opencodeModels = opencodeModels.map((m) => ({ id: m.id || randomUUID(), name: m.name, label: m.label || m.name, @@ -5818,7 +5816,7 @@ async function loadAdminModelStore() { multiplier: m.multiplier || getTierMultiplier(m.tier), })).filter((m) => !!m.name); - // Ensure all public models have required fields + // Validate public models publicModels = publicModels.map((m) => ({ id: m.id || randomUUID(), name: m.name, @@ -5829,25 +5827,23 @@ async function loadAdminModelStore() { multiplier: m.multiplier || getTierMultiplier(m.tier), })).filter((m) => !!m.name); - // Ensure all provider chain entries have required fields - providerChain = providerChain.map((p, idx) => ({ + // Validate chain + opencodeChain = opencodeChain.map((p) => ({ provider: normalizeProviderName(p.provider || 'opencode'), model: (p.model || '').trim(), - primary: idx === 0, })).filter((p) => !!p.model); refreshAdminModelIndex(); log('Loaded admin model store', { - providerModels: providerModels.length, - publicModels: publicModels.length, - providerChain: providerChain.length, - legacyAdminModels: adminModels.length + opencodeModels: opencodeModels.length, + opencodeChain: opencodeChain.length, + publicModels: publicModels.length, }); } catch (error) { log('Failed to load admin models, starting empty', { error: String(error) }); - providerModels = []; + opencodeModels = []; + opencodeChain = []; publicModels = []; - providerChain = []; adminModels = []; refreshAdminModelIndex(); } @@ -5856,13 +5852,13 @@ async function loadAdminModelStore() { async function persistAdminModels() { await ensureStateFile(); await ensureAssetsDir(); - // Save new unified structure + // Save clean structure const payload = JSON.stringify({ - providerModels, + opencodeModels, + opencodeChain, publicModels, - providerChain, adminModels, // Keep legacy for backwards compatibility - version: 3, // Schema version - added providerModels + version: 4, // Clean structure }, null, 2); await safeWriteFile(ADMIN_MODELS_FILE, payload); refreshAdminModelIndex(); @@ -10278,8 +10274,8 @@ function buildOpencodeAttemptChain(cli, preferredModel) { const chain = []; const seen = new Set(); - // Build chain from unified providerChain with user's preferred model - providerChain.forEach((p, idx) => { + // Build chain from opencodeChain with user's preferred model + opencodeChain.forEach((p, idx) => { const modelToUse = idx === 0 && preferredModel ? preferredModel : p.model; const key = `${p.provider}:${modelToUse}`; if (seen.has(key)) return; @@ -10293,31 +10289,15 @@ function buildOpencodeAttemptChain(cli, preferredModel) { }); }); - // If no provider chain, fall back to old behavior + // If no chain configured, fall back to default if (chain.length === 0) { - const addProviderOptions = (modelName) => { - const providers = resolveModelProviders(modelName); - providers.forEach((p, idx) => { - const key = `${p.provider}:${p.model || modelName}`; - if (seen.has(key)) return; - seen.add(key); - chain.push({ - provider: p.provider, - model: p.model || modelName, - primary: typeof p.primary === 'boolean' ? p.primary : idx === 0, - cli: normalizeCli(p.cli || cli || 'opencode'), - sourceModel: modelName, - }); - }); - }; - - if (typeof preferredModel === 'string' && preferredModel.trim()) { - addProviderOptions(preferredModel); - } - getConfiguredModels(cli).forEach((m) => { - if (m.name && m.name !== preferredModel) addProviderOptions(m.name); + chain.push({ + provider: 'opencode', + model: preferredModel || 'default', + primary: true, + cli: normalizeCli(cli || 'opencode'), + sourceModel: preferredModel || 'default', }); - addProviderOptions('default'); } // Log the built chain for debugging @@ -11358,20 +11338,20 @@ async function processMessage(sessionId, message) { function getConfiguredModels(cliParam = 'opencode') { const cli = normalizeCli(cliParam || 'opencode'); - // Use new publicModels array - filter by CLI if needed (though public models don't have CLI field) - const mapped = publicModels.map((m) => ({ + // Return opencode models for backwards compatibility + const mapped = opencodeModels.map((m) => ({ id: m.id, name: m.name, label: m.label || m.name, icon: m.icon || '', - cli: cli, // All public models work with opencode CLI - providers: providerChain, // Use unified provider chain - primaryProvider: providerChain[0]?.provider || 'opencode', + cli: cli, + providers: opencodeChain, + primaryProvider: opencodeChain[0]?.provider || 'opencode', tier: m.tier || 'free', multiplier: getTierMultiplier(m.tier || 'free'), supportsMedia: m.supportsMedia ?? false, })); - return mapped.sort((a, b) => (a.label || '').localeCompare(b.label || '')); + return mapped; } async function handleModels(_req, res, cliParam = null) { @@ -15874,11 +15854,11 @@ async function handleAdminListIcons(req, res) { async function handleAdminModelsList(req, res) { const session = requireAdminAuth(req, res); if (!session) return; - // Return new unified structure + // Return clean structure sendJson(res, 200, { - providerModels, + opencodeModels, + opencodeChain, publicModels, - providerChain, // Legacy support models: getConfiguredModels('opencode'), }); @@ -15889,132 +15869,54 @@ async function handleAdminModelUpsert(req, res) { if (!session) return; try { const body = await parseJsonBody(req); + const modelName = (body.name || body.model || '').trim(); + const label = (body.label || body.displayName || modelName).trim(); + const tier = normalizeTier(body.tier); - // Check if this is a provider model, public model, or provider chain update - if (body.type === 'providerModel') { - // Handle provider model update (from OpenCode) - const modelName = (body.name || body.model || '').trim(); - const label = (body.label || body.displayName || modelName).trim(); - const tier = normalizeTier(body.tier); - if (!modelName) return sendJson(res, 400, { error: 'Model name is required' }); - const id = body.id || randomUUID(); - const existingIndex = providerModels.findIndex((m) => m.id === id); - let icon = providerModels[existingIndex]?.icon || ''; - if (typeof body.icon === 'string' && body.icon.trim()) { - icon = await normalizeIconPath(body.icon); - if (!icon) return sendJson(res, 400, { error: 'Icon must be stored in /assets' }); - } - const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false; - - const payload = { - id, - name: modelName, - label: label || modelName, - icon, - tier, - supportsMedia, - multiplier: getTierMultiplier(tier), - }; - - if (existingIndex >= 0) providerModels[existingIndex] = { ...providerModels[existingIndex], ...payload }; - else providerModels.push(payload); + if (!modelName) return sendJson(res, 400, { error: 'Model name is required' }); + + let icon = ''; + if (typeof body.icon === 'string' && body.icon.trim()) { + icon = await normalizeIconPath(body.icon) || ''; + } + const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false; + + const payload = { + id: body.id || randomUUID(), + name: modelName, + label: label || modelName, + icon, + tier, + supportsMedia, + multiplier: getTierMultiplier(tier), + }; + + if (body.type === 'opencode') { + // Add to opencode models + const existingIndex = body.id ? opencodeModels.findIndex((m) => m.id === body.id) : -1; + if (existingIndex >= 0) opencodeModels[existingIndex] = { ...opencodeModels[existingIndex], ...payload }; + else opencodeModels.push(payload); await persistAdminModels(); sendJson(res, 200, { - providerModel: payload, - providerModels, + opencodeModels, + opencodeChain, publicModels, - providerChain, - models: getConfiguredModels('opencode'), }); - } else if (body.type === 'publicModel') { - // Handle public model update (completely separate from OpenCode) - const modelName = (body.name || body.model || '').trim(); - const label = (body.label || body.displayName || modelName).trim(); - const tier = normalizeTier(body.tier); - if (!modelName) return sendJson(res, 400, { error: 'Model name is required' }); - const id = body.id || randomUUID(); - const existingIndex = publicModels.findIndex((m) => m.id === id); - let icon = publicModels[existingIndex]?.icon || ''; - if (typeof body.icon === 'string' && body.icon.trim()) { - icon = await normalizeIconPath(body.icon); - if (!icon) return sendJson(res, 400, { error: 'Icon must be stored in /assets' }); - } - const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false; - - const payload = { - id, - name: modelName, - label: label || modelName, - icon, - tier, - supportsMedia, - multiplier: getTierMultiplier(tier), - }; - + } else if (body.type === 'public') { + // Add to public models + const existingIndex = body.id ? publicModels.findIndex((m) => m.id === body.id) : -1; if (existingIndex >= 0) publicModels[existingIndex] = { ...publicModels[existingIndex], ...payload }; else publicModels.push(payload); await persistAdminModels(); sendJson(res, 200, { - publicModel: payload, - providerModels, + opencodeModels, + opencodeChain, publicModels, - providerChain, - models: getConfiguredModels('opencode'), - }); - } else if (body.type === 'providerChain') { - // Handle provider chain update - if (!Array.isArray(body.chain)) { - return sendJson(res, 400, { error: 'Provider chain must be an array' }); - } - - providerChain = body.chain.map((p, idx) => ({ - provider: normalizeProviderName(p.provider || 'opencode'), - model: (p.model || '').trim(), - primary: idx === 0, - })).filter((p) => !!p.model); - - await persistAdminModels(); - sendJson(res, 200, { - providerChain, - publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')), - models: getConfiguredModels('opencode'), }); } else { - // Legacy support - treat as public model - const modelName = (body.name || body.model || '').trim(); - if (!modelName) return sendJson(res, 400, { error: 'Model name is required' }); - const id = body.id || randomUUID(); - const label = (body.label || body.displayName || modelName).trim(); - const tier = normalizeTier(body.tier); - let icon = ''; - if (typeof body.icon === 'string' && body.icon.trim()) { - icon = await normalizeIconPath(body.icon) || ''; - } - const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false; - - const payload = { - id, - name: modelName, - label: label || modelName, - icon, - tier, - supportsMedia, - multiplier: getTierMultiplier(tier), - }; - - const existingIndex = publicModels.findIndex((m) => m.id === id); - if (existingIndex >= 0) publicModels[existingIndex] = { ...publicModels[existingIndex], ...payload }; - else publicModels.push(payload); - - await persistAdminModels(); - sendJson(res, 200, { - model: payload, - publicModels: publicModels, - providerChain, - models: getConfiguredModels('opencode'), - }); + return sendJson(res, 400, { error: 'Invalid type. Use "opencode" or "public"' }); } } catch (error) { sendJson(res, 400, { error: error.message || 'Unable to save model' }); @@ -16030,11 +15932,11 @@ async function handleAdminModelsReorder(req, res) { return sendJson(res, 400, { error: 'models array is required' }); } - const reorderType = body.type || 'publicModels'; + const reorderType = body.type || 'public'; - if (reorderType === 'providerModels') { - // Validate that all provided IDs exist in providerModels - const currentIds = new Set(providerModels.map(m => m.id)); + if (reorderType === 'opencode') { + // Validate that all provided IDs exist in opencodeModels + const currentIds = new Set(opencodeModels.map(m => m.id)); const newIds = body.models.map(m => m.id); const allExist = newIds.every(id => currentIds.has(id)); @@ -16042,14 +15944,14 @@ async function handleAdminModelsReorder(req, res) { return sendJson(res, 400, { error: 'Invalid model IDs in reorder request' }); } - // Reorder providerModels based on the provided order + // Reorder opencodeModels based on the provided order const reordered = []; body.models.forEach(m => { - const model = providerModels.find(pm => pm.id === m.id); + const model = opencodeModels.find(pm => pm.id === m.id); if (model) reordered.push(model); }); - providerModels = reordered; + opencodeModels = reordered; } else { // Validate that all provided IDs exist in publicModels const currentIds = new Set(publicModels.map(m => m.id)); @@ -16074,10 +15976,9 @@ async function handleAdminModelsReorder(req, res) { sendJson(res, 200, { ok: true, - providerModels, + opencodeModels, + opencodeChain, publicModels, - providerChain, - models: getConfiguredModels('opencode'), }); } catch (error) { sendJson(res, 400, { error: error.message || 'Unable to reorder models' }); @@ -16088,30 +15989,93 @@ async function handleAdminModelDelete(req, res, id) { const session = requireAdminAuth(req, res); if (!session) return; - // Try to delete from providerModels first - const beforeProvider = providerModels.length; - providerModels = providerModels.filter((m) => m.id !== id); - const deletedFromProvider = providerModels.length < beforeProvider; + const url = new URL(req.url, `http://${req.headers.host}`); + const deleteType = url.searchParams.get('type') || 'public'; - // Try to delete from publicModels - const beforePublic = publicModels.length; - publicModels = publicModels.filter((m) => m.id !== id); - const deletedFromPublic = publicModels.length < beforePublic; - - if (!deletedFromProvider && !deletedFromPublic) { - return sendJson(res, 404, { error: 'Model not found' }); + if (deleteType === 'opencode') { + const before = opencodeModels.length; + opencodeModels = opencodeModels.filter((m) => m.id !== id); + if (opencodeModels.length === before) { + return sendJson(res, 404, { error: 'Model not found' }); + } + } else { + const before = publicModels.length; + publicModels = publicModels.filter((m) => m.id !== id); + if (publicModels.length === before) { + return sendJson(res, 404, { error: 'Model not found' }); + } } await persistAdminModels(); sendJson(res, 200, { ok: true, - providerModels, + opencodeModels, + opencodeChain, publicModels, - providerChain, - models: getConfiguredModels('opencode'), }); } +// Chain handlers +async function handleAdminChainPost(req, res) { + const session = requireAdminAuth(req, res); + if (!session) return; + try { + const body = await parseJsonBody(req); + if (!Array.isArray(body.chain)) { + return sendJson(res, 400, { error: 'chain must be an array' }); + } + + opencodeChain = body.chain.map((p) => ({ + provider: normalizeProviderName(p.provider || 'opencode'), + model: (p.model || '').trim(), + })).filter((p) => !!p.model); + + await persistAdminModels(); + sendJson(res, 200, { + opencodeChain, + opencodeModels, + publicModels, + }); + } catch (error) { + sendJson(res, 400, { error: error.message || 'Unable to update chain' }); + } +} + +async function handleAdminChainReorder(req, res) { + const session = requireAdminAuth(req, res); + if (!session) return; + try { + const body = await parseJsonBody(req); + if (!Array.isArray(body.chain)) { + return sendJson(res, 400, { error: 'chain array is required' }); + } + + // Validate all entries + const currentIds = new Set(opencodeChain.map((_, i) => i)); + const newIds = body.chain.map((_, i) => i); + const allExist = newIds.every((_, i) => currentIds.has(i)); + + if (!allExist || body.chain.length !== opencodeChain.length) { + return sendJson(res, 400, { error: 'Invalid chain order' }); + } + + // Reorder + opencodeChain = body.chain.map((p) => ({ + provider: normalizeProviderName(p.provider || 'opencode'), + model: (p.model || '').trim(), + })).filter((p) => !!p.model); + + await persistAdminModels(); + sendJson(res, 200, { + opencodeChain, + opencodeModels, + publicModels, + }); + } catch (error) { + sendJson(res, 400, { error: error.message || 'Unable to reorder chain' }); + } +} + async function handleAdminOpenRouterSettingsGet(req, res) { const session = requireAdminAuth(req, res); if (!session) return; @@ -19012,6 +18976,8 @@ async function routeInternal(req, res, url, pathname) { if (req.method === 'GET' && pathname === '/api/admin/models') return handleAdminModelsList(req, res); if (req.method === 'POST' && pathname === '/api/admin/models') return handleAdminModelUpsert(req, res); if (req.method === 'POST' && pathname === '/api/admin/models/reorder') return handleAdminModelsReorder(req, res); + if (req.method === 'POST' && pathname === '/api/admin/chain') return handleAdminChainPost(req, res); + if (req.method === 'POST' && pathname === '/api/admin/chain/reorder') return handleAdminChainReorder(req, res); if (req.method === 'GET' && pathname === '/api/admin/openrouter-settings') return handleAdminOpenRouterSettingsGet(req, res); if (req.method === 'POST' && pathname === '/api/admin/openrouter-settings') return handleAdminOpenRouterSettingsPost(req, res); if (req.method === 'GET' && pathname === '/api/admin/mistral-settings') return handleAdminMistralSettingsGet(req, res);