Files
shopify-ai-backup/chat/public/admin.js
southseact-3d f5fee2ac4d Remove freePlanModel and opencodeBackupModel settings
Users now use OpenCode Models (Fallback Chain) for model selection.
- Removed Auto Model for Hobby/Free Plan section from admin panel
- Removed OpenCode Ultimate Backup Model section from admin panel
- Updated server to use opencodeModels for free plan users
- Removed backup model fallback logic (opencodeModels chain handles this)
2026-02-19 13:25:54 +00:00

2816 lines
106 KiB
JavaScript

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