The populateOpencodeModelSelect() was called from loadConfigured() which runs in parallel with loadAvailable() in init(). This caused a race condition where the dropdown could be populated before state.available was set. Fixed by moving the populateOpencodeModelSelect() call to after all loaders complete in init(), ensuring state.available is populated first.
3060 lines
115 KiB
JavaScript
3060 lines
115 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', freePlanModel: '', planningChain: [] },
|
|
providerLimits: {},
|
|
providerUsage: [],
|
|
opencodeBackupModel: '',
|
|
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'),
|
|
autoModelForm: document.getElementById('auto-model-form'),
|
|
autoModelSelect: document.getElementById('auto-model-select'),
|
|
autoModelStatus: document.getElementById('auto-model-status'),
|
|
planProviderForm: document.getElementById('plan-provider-form'),
|
|
planProvider: document.getElementById('plan-provider'),
|
|
freePlanModel: document.getElementById('free-plan-model'),
|
|
planProviderStatus: document.getElementById('plan-provider-status'),
|
|
planPriorityList: document.getElementById('plan-priority-list'),
|
|
addPlanRow: document.getElementById('add-plan-row'),
|
|
planChainStatus: document.getElementById('plan-chain-status'),
|
|
mistralForm: document.getElementById('mistral-form'),
|
|
mistralPrimary: document.getElementById('mistral-primary'),
|
|
mistralBackup1: document.getElementById('mistral-backup1'),
|
|
mistralBackup2: document.getElementById('mistral-backup2'),
|
|
mistralBackup3: document.getElementById('mistral-backup3'),
|
|
mistralStatus: document.getElementById('mistral-status'),
|
|
accountsTable: document.getElementById('accounts-table'),
|
|
accountsCount: document.getElementById('accounts-count'),
|
|
affiliatesTable: document.getElementById('affiliates-table'),
|
|
affiliatesCount: document.getElementById('affiliates-count'),
|
|
withdrawalsTable: document.getElementById('withdrawals-table'),
|
|
withdrawalsCount: document.getElementById('withdrawals-count'),
|
|
providerOrder: document.getElementById('provider-order'),
|
|
providerLimitForm: document.getElementById('provider-limit-form'),
|
|
limitProvider: document.getElementById('limit-provider'),
|
|
limitScope: document.getElementById('limit-scope'),
|
|
limitModel: document.getElementById('limit-model'),
|
|
limitModelInput: document.getElementById('limit-model-input'),
|
|
limitTpm: document.getElementById('limit-tpm'),
|
|
limitTph: document.getElementById('limit-tph'),
|
|
limitTpd: document.getElementById('limit-tpd'),
|
|
limitRpm: document.getElementById('limit-rpm'),
|
|
limitRph: document.getElementById('limit-rph'),
|
|
limitRpd: document.getElementById('limit-rpd'),
|
|
limitBackup: document.getElementById('limit-backup'),
|
|
providerLimitStatus: document.getElementById('provider-limit-status'),
|
|
providerUsage: document.getElementById('provider-usage'),
|
|
availableModelDatalist: document.getElementById('available-model-datalist'),
|
|
supportsMedia: document.getElementById('supports-media'),
|
|
// Plan tokens UI
|
|
planTokensTable: document.getElementById('plan-tokens-table'),
|
|
savePlanTokens: document.getElementById('save-plan-tokens'),
|
|
planTokensStatus: document.getElementById('plan-tokens-status'),
|
|
tokenRateUsd: document.getElementById('token-rate-usd'),
|
|
tokenRateGbp: document.getElementById('token-rate-gbp'),
|
|
tokenRateEur: document.getElementById('token-rate-eur'),
|
|
saveTokenRates: document.getElementById('save-token-rates'),
|
|
tokenRatesStatus: document.getElementById('token-rates-status'),
|
|
// Cancel messages UI
|
|
cancelAllMessages: document.getElementById('cancel-all-messages'),
|
|
cancelMessagesStatus: document.getElementById('cancel-messages-status'),
|
|
opencodeBackupForm: document.getElementById('opencode-backup-form'),
|
|
opencodeBackup: document.getElementById('opencode-backup'),
|
|
opencodeBackupStatus: document.getElementById('opencode-backup-status'),
|
|
externalTestingRun: document.getElementById('external-testing-run'),
|
|
externalTestingStatus: document.getElementById('external-testing-status'),
|
|
externalTestingOutput: document.getElementById('external-testing-output'),
|
|
externalTestingConfig: document.getElementById('external-testing-config'),
|
|
ollamaTestRun: document.getElementById('ollama-test-run'),
|
|
ollamaTestStatus: document.getElementById('ollama-test-status'),
|
|
ollamaTestOutput: document.getElementById('ollama-test-output'),
|
|
};
|
|
console.log('Element check - opencodeBackupForm:', el.opencodeBackupForm);
|
|
console.log('Element check - opencodeBackup:', el.opencodeBackup);
|
|
console.log('Element check - opencodeBackupStatus:', el.opencodeBackupStatus);
|
|
|
|
function ensureAvailableDatalist() {
|
|
if (el.availableModelDatalist) return el.availableModelDatalist;
|
|
const dl = document.createElement('datalist');
|
|
dl.id = 'available-model-datalist';
|
|
document.body.appendChild(dl);
|
|
el.availableModelDatalist = dl;
|
|
return dl;
|
|
}
|
|
|
|
function getAvailableModelNames() {
|
|
const names = new Set();
|
|
(state.available || []).forEach((m) => {
|
|
const name = m.name || m.id || m;
|
|
if (name) names.add(name);
|
|
});
|
|
(state.configured || []).forEach((m) => { if (m.name) names.add(m.name); });
|
|
// include provider-specific models discovered by provider limits endpoint
|
|
Object.values(state.providerModels || {}).forEach((arr) => {
|
|
(arr || []).forEach((name) => { if (name) names.add(name); });
|
|
});
|
|
return Array.from(names);
|
|
}
|
|
|
|
function syncAvailableModelDatalist() {
|
|
const dl = ensureAvailableDatalist();
|
|
if (!dl) return;
|
|
dl.innerHTML = '';
|
|
getAvailableModelNames().forEach((name) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = name;
|
|
dl.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
function setStatus(msg, isError = false) {
|
|
if (!el.status) return;
|
|
el.status.textContent = msg || '';
|
|
el.status.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function setOrStatus(msg, isError = false) {
|
|
if (!el.orStatus) return;
|
|
el.orStatus.textContent = msg || '';
|
|
el.orStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function setMistralStatus(msg, isError = false) {
|
|
if (!el.mistralStatus) return;
|
|
el.mistralStatus.textContent = msg || '';
|
|
el.mistralStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function setPlanProviderStatus(msg, isError = false) {
|
|
if (!el.planProviderStatus) return;
|
|
el.planProviderStatus.textContent = msg || '';
|
|
el.planProviderStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function setPlanChainStatus(msg, isError = false) {
|
|
if (!el.planChainStatus) return;
|
|
el.planChainStatus.textContent = msg || '';
|
|
el.planChainStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function setProviderLimitStatus(msg, isError = false) {
|
|
if (!el.providerLimitStatus) return;
|
|
el.providerLimitStatus.textContent = msg || '';
|
|
el.providerLimitStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function setAutoModelStatus(msg, isError = false) {
|
|
if (!el.autoModelStatus) return;
|
|
el.autoModelStatus.textContent = msg || '';
|
|
el.autoModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function setPublicModelStatus(msg, isError = false) {
|
|
if (!el.publicModelStatus) return;
|
|
el.publicModelStatus.textContent = msg || '';
|
|
el.publicModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function 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 setOpencodeBackupStatus(msg, isError = false) {
|
|
if (!el.opencodeBackupStatus) return;
|
|
el.opencodeBackupStatus.textContent = msg || '';
|
|
el.opencodeBackupStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function setExternalTestingStatus(msg, isError = false) {
|
|
if (!el.externalTestingStatus) return;
|
|
el.externalTestingStatus.textContent = msg || '';
|
|
el.externalTestingStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function renderExternalTestingConfig(config) {
|
|
if (!el.externalTestingConfig) return;
|
|
el.externalTestingConfig.innerHTML = '';
|
|
if (!config) return;
|
|
const rows = [
|
|
['WP host', config.wpHost || '—'],
|
|
['WP path', config.wpPath || '—'],
|
|
['Base URL', config.wpBaseUrl || '—'],
|
|
['Multisite enabled', config.enableMultisite ? 'Yes' : 'No'],
|
|
['Subsite mode', config.subsiteMode || '—'],
|
|
['Subsite domain', config.subsiteDomain || '—'],
|
|
['Max concurrent tests', String(config.maxConcurrentTests ?? '—')],
|
|
['Auto cleanup', config.autoCleanup ? 'Yes' : 'No'],
|
|
['Cleanup delay (ms)', String(config.cleanupDelayMs ?? '—')],
|
|
['SSH key configured', config.sshKeyConfigured ? 'Yes' : 'No'],
|
|
];
|
|
|
|
rows.forEach(([label, value]) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'admin-row';
|
|
const labelWrap = document.createElement('div');
|
|
labelWrap.style.minWidth = '180px';
|
|
const strong = document.createElement('strong');
|
|
strong.textContent = label;
|
|
labelWrap.appendChild(strong);
|
|
const valueWrap = document.createElement('div');
|
|
valueWrap.textContent = value;
|
|
row.appendChild(labelWrap);
|
|
row.appendChild(valueWrap);
|
|
el.externalTestingConfig.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function renderExternalTestingOutput(result) {
|
|
if (!el.externalTestingOutput) return;
|
|
el.externalTestingOutput.innerHTML = '';
|
|
if (!result) return;
|
|
|
|
const summary = document.createElement('div');
|
|
summary.className = 'admin-row';
|
|
const summaryLabel = document.createElement('div');
|
|
summaryLabel.style.minWidth = '180px';
|
|
const summaryStrong = document.createElement('strong');
|
|
summaryStrong.textContent = 'Overall result';
|
|
summaryLabel.appendChild(summaryStrong);
|
|
const summaryValue = document.createElement('div');
|
|
summaryValue.textContent = result.ok ? 'Passed' : 'Failed';
|
|
summary.appendChild(summaryLabel);
|
|
summary.appendChild(summaryValue);
|
|
el.externalTestingOutput.appendChild(summary);
|
|
|
|
const detailRows = [
|
|
['Subsite URL', result.subsite_url || '—'],
|
|
['Duration', typeof result.duration === 'number' ? `${(result.duration / 1000).toFixed(1)}s` : '—'],
|
|
];
|
|
detailRows.forEach(([label, value]) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'admin-row';
|
|
const labelWrap = document.createElement('div');
|
|
labelWrap.style.minWidth = '180px';
|
|
const strong = document.createElement('strong');
|
|
strong.textContent = label;
|
|
labelWrap.appendChild(strong);
|
|
const valueWrap = document.createElement('div');
|
|
valueWrap.textContent = value;
|
|
row.appendChild(labelWrap);
|
|
row.appendChild(valueWrap);
|
|
el.externalTestingOutput.appendChild(row);
|
|
});
|
|
|
|
const scenarioResults = result?.test_results?.cli_tests?.results || [];
|
|
if (scenarioResults.length) {
|
|
scenarioResults.forEach((scenario) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'admin-row';
|
|
const labelWrap = document.createElement('div');
|
|
labelWrap.style.minWidth = '180px';
|
|
const strong = document.createElement('strong');
|
|
strong.textContent = scenario.name || 'Scenario';
|
|
labelWrap.appendChild(strong);
|
|
const valueWrap = document.createElement('div');
|
|
valueWrap.textContent = scenario.status === 'passed' ? 'Passed' : 'Failed';
|
|
if (scenario.status !== 'passed') {
|
|
valueWrap.style.color = 'var(--danger)';
|
|
}
|
|
row.appendChild(labelWrap);
|
|
row.appendChild(valueWrap);
|
|
el.externalTestingOutput.appendChild(row);
|
|
});
|
|
}
|
|
|
|
const errors = Array.isArray(result.errors) ? result.errors : [];
|
|
if (errors.length) {
|
|
errors.forEach((err) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'admin-row';
|
|
const labelWrap = document.createElement('div');
|
|
labelWrap.style.minWidth = '180px';
|
|
const strong = document.createElement('strong');
|
|
strong.textContent = 'Error';
|
|
labelWrap.appendChild(strong);
|
|
const valueWrap = document.createElement('div');
|
|
valueWrap.textContent = err;
|
|
valueWrap.style.color = 'var(--danger)';
|
|
row.appendChild(labelWrap);
|
|
row.appendChild(valueWrap);
|
|
el.externalTestingOutput.appendChild(row);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadExternalTestingStatus() {
|
|
const data = await api('/api/admin/external-testing-status');
|
|
renderExternalTestingConfig(data.config || {});
|
|
}
|
|
|
|
// --- Ollama Test UI ---
|
|
function setOllamaTestStatus(msg, isError = false) {
|
|
if (!el.ollamaTestStatus) return;
|
|
el.ollamaTestStatus.textContent = msg || '';
|
|
el.ollamaTestStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
function renderOllamaTestOutput(data) {
|
|
if (!el.ollamaTestOutput) return;
|
|
el.ollamaTestOutput.innerHTML = '';
|
|
if (!data) return;
|
|
|
|
// Config section
|
|
const configSection = document.createElement('div');
|
|
configSection.style.marginBottom = '16px';
|
|
configSection.style.padding = '12px';
|
|
configSection.style.background = 'var(--surface)';
|
|
configSection.style.borderRadius = '6px';
|
|
|
|
const configTitle = document.createElement('div');
|
|
configTitle.style.fontWeight = '600';
|
|
configTitle.style.marginBottom = '8px';
|
|
configTitle.textContent = 'Configuration';
|
|
configSection.appendChild(configTitle);
|
|
|
|
const configRows = [
|
|
['URL', data.config?.url || '—'],
|
|
['Model', data.config?.model || '—'],
|
|
['API Key Configured', data.config?.apiKeyConfigured ? 'Yes' : 'No'],
|
|
['API Key Preview', data.config?.apiKeyPreview || '—'],
|
|
];
|
|
|
|
configRows.forEach(([label, value]) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'admin-row';
|
|
row.style.marginBottom = '4px';
|
|
const labelWrap = document.createElement('div');
|
|
labelWrap.style.minWidth = '140px';
|
|
labelWrap.style.fontSize = '12px';
|
|
labelWrap.style.color = 'var(--muted)';
|
|
labelWrap.textContent = label;
|
|
const valueWrap = document.createElement('div');
|
|
valueWrap.style.fontSize = '12px';
|
|
valueWrap.textContent = value;
|
|
row.appendChild(labelWrap);
|
|
row.appendChild(valueWrap);
|
|
configSection.appendChild(row);
|
|
});
|
|
|
|
el.ollamaTestOutput.appendChild(configSection);
|
|
|
|
// Result section
|
|
if (data.result) {
|
|
const resultSection = document.createElement('div');
|
|
resultSection.style.marginBottom = '16px';
|
|
resultSection.style.padding = '12px';
|
|
resultSection.style.background = 'rgba(0, 200, 0, 0.1)';
|
|
resultSection.style.borderRadius = '6px';
|
|
resultSection.style.border = '1px solid var(--shopify-green)';
|
|
|
|
const resultTitle = document.createElement('div');
|
|
resultTitle.style.fontWeight = '600';
|
|
resultTitle.style.marginBottom = '8px';
|
|
resultTitle.style.color = 'var(--shopify-green)';
|
|
resultTitle.textContent = `✓ Test Passed (${data.duration}ms)`;
|
|
resultSection.appendChild(resultTitle);
|
|
|
|
const resultRows = [
|
|
['Response', data.result.reply || '—'],
|
|
['Model Used', data.result.model || '—'],
|
|
];
|
|
|
|
resultRows.forEach(([label, value]) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'admin-row';
|
|
row.style.marginBottom = '4px';
|
|
const labelWrap = document.createElement('div');
|
|
labelWrap.style.minWidth = '140px';
|
|
labelWrap.style.fontSize = '12px';
|
|
labelWrap.style.color = 'var(--muted)';
|
|
labelWrap.textContent = label;
|
|
const valueWrap = document.createElement('div');
|
|
valueWrap.style.fontSize = '12px';
|
|
valueWrap.textContent = value;
|
|
row.appendChild(labelWrap);
|
|
row.appendChild(valueWrap);
|
|
resultSection.appendChild(row);
|
|
});
|
|
|
|
el.ollamaTestOutput.appendChild(resultSection);
|
|
}
|
|
|
|
// Error section
|
|
if (data.error) {
|
|
const errorSection = document.createElement('div');
|
|
errorSection.style.marginBottom = '16px';
|
|
errorSection.style.padding = '12px';
|
|
errorSection.style.background = 'rgba(255, 0, 0, 0.05)';
|
|
errorSection.style.borderRadius = '6px';
|
|
errorSection.style.border = '1px solid var(--danger)';
|
|
|
|
const errorTitle = document.createElement('div');
|
|
errorTitle.style.fontWeight = '600';
|
|
errorTitle.style.marginBottom = '8px';
|
|
errorTitle.style.color = 'var(--danger)';
|
|
errorTitle.textContent = `✗ Test Failed (${data.duration}ms)`;
|
|
errorSection.appendChild(errorTitle);
|
|
|
|
const errorRows = [
|
|
['Error Message', data.error.message || '—'],
|
|
['Status Code', data.error.status || '—'],
|
|
['Detail', data.error.detail || '—'],
|
|
['Auth Error', data.error.isAuthError ? 'Yes' : 'No'],
|
|
['Model Missing', data.error.isModelMissing ? 'Yes' : 'No'],
|
|
['Error Code', data.error.code || '—'],
|
|
];
|
|
|
|
errorRows.forEach(([label, value]) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'admin-row';
|
|
row.style.marginBottom = '4px';
|
|
const labelWrap = document.createElement('div');
|
|
labelWrap.style.minWidth = '140px';
|
|
labelWrap.style.fontSize = '12px';
|
|
labelWrap.style.color = 'var(--muted)';
|
|
labelWrap.textContent = label;
|
|
const valueWrap = document.createElement('div');
|
|
valueWrap.style.fontSize = '12px';
|
|
valueWrap.style.color = label === 'Error Message' ? 'var(--danger)' : 'inherit';
|
|
valueWrap.textContent = value;
|
|
row.appendChild(labelWrap);
|
|
row.appendChild(valueWrap);
|
|
errorSection.appendChild(row);
|
|
});
|
|
|
|
el.ollamaTestOutput.appendChild(errorSection);
|
|
}
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const res = await fetch(path, {
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
|
...options,
|
|
});
|
|
const 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();
|
|
|
|
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
|
|
? state.planSettings.freePlanModel
|
|
: (el.autoModelSelect ? el.autoModelSelect.value : '');
|
|
populateAutoModelOptions(selectedAutoModel);
|
|
|
|
if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter');
|
|
}
|
|
|
|
async function loadIcons() {
|
|
const data = await api('/api/admin/icons');
|
|
state.icons = data.icons || [];
|
|
renderIcons();
|
|
}
|
|
|
|
async function loadConfigured() {
|
|
const data = await api('/api/admin/models');
|
|
// Handle new unified structure
|
|
state.opencodeModels = data.opencodeModels || [];
|
|
state.publicModels = data.publicModels || [];
|
|
state.configured = data.models || []; // Legacy support
|
|
renderOpencodeModels();
|
|
renderPublicModels();
|
|
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
|
|
? state.planSettings.freePlanModel
|
|
: (el.autoModelSelect ? el.autoModelSelect.value : '');
|
|
populateAutoModelOptions(selectedAutoModel);
|
|
populateFreePlanModelOptions(selectedAutoModel);
|
|
syncAvailableModelDatalist();
|
|
if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter');
|
|
}
|
|
|
|
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 ?? '';
|
|
if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || '';
|
|
}
|
|
|
|
async function loadProviderLimits() {
|
|
if (!el.providerUsage && !el.providerLimitForm) return;
|
|
try {
|
|
const data = await api('/api/admin/provider-limits');
|
|
state.providerLimits = data.limits || {};
|
|
state.providerOptions = data.providers || [];
|
|
state.providerModels = data.providerModels || {};
|
|
(state.providerOptions || []).forEach((provider) => {
|
|
if (provider && !state.providerLimits[provider]) state.providerLimits[provider] = {};
|
|
});
|
|
DEFAULT_PROVIDERS.forEach((p) => {
|
|
if (!state.providerLimits[p]) state.providerLimits[p] = {};
|
|
});
|
|
state.providerUsage = data.usage || [];
|
|
state.opencodeBackupModel = data.opencodeBackupModel || '';
|
|
renderProviderOptions();
|
|
populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider');
|
|
renderProviderUsage();
|
|
if (el.limitBackup && state.opencodeBackupModel !== undefined) el.limitBackup.value = state.opencodeBackupModel || '';
|
|
populateOpencodeBackupOptions(state.opencodeBackupModel);
|
|
// refresh datalist with provider-specific models
|
|
syncAvailableModelDatalist();
|
|
renderPlanPriority();
|
|
} catch (err) {
|
|
setProviderLimitStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
// --- Plan tokens UI ---
|
|
async function loadPlanTokens() {
|
|
if (!el.planTokensTable) return;
|
|
try {
|
|
const data = await api('/api/admin/plan-tokens');
|
|
state.planTokens = data.limits || {};
|
|
renderPlanTokens();
|
|
} catch (err) {
|
|
if (el.planTokensStatus) el.planTokensStatus.textContent = String(err.message || err);
|
|
}
|
|
}
|
|
|
|
function renderPlanTokens() {
|
|
if (!el.planTokensTable) return;
|
|
el.planTokensTable.innerHTML = '';
|
|
const plansOrder = ['hobby', 'starter', 'business', 'enterprise'];
|
|
plansOrder.forEach((plan) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'admin-row';
|
|
const left = document.createElement('div');
|
|
left.style.display = 'flex';
|
|
left.style.flexDirection = 'column';
|
|
left.style.minWidth = '180px';
|
|
const title = document.createElement('strong');
|
|
title.textContent = plan;
|
|
left.appendChild(title);
|
|
left.style.marginBottom = '8px';
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'number';
|
|
input.min = '0';
|
|
input.step = '1';
|
|
input.value = (state.planTokens && typeof state.planTokens[plan] === 'number') ? String(state.planTokens[plan]) : '';
|
|
input.dataset.plan = plan;
|
|
input.placeholder = 'Token limit';
|
|
input.style.width = '200px';
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.style.display = 'flex';
|
|
wrapper.style.flexDirection = 'column';
|
|
wrapper.style.gap = '4px';
|
|
|
|
const label = document.createElement('div');
|
|
label.textContent = 'Token Limit';
|
|
label.style.fontSize = '12px';
|
|
label.style.color = 'var(--muted)';
|
|
wrapper.appendChild(label);
|
|
wrapper.appendChild(input);
|
|
|
|
card.appendChild(left);
|
|
card.appendChild(wrapper);
|
|
el.planTokensTable.appendChild(card);
|
|
});
|
|
}
|
|
|
|
async function savePlanTokens() {
|
|
if (!el.planTokensTable) return;
|
|
if (el.savePlanTokens) el.savePlanTokens.disabled = true;
|
|
if (el.planTokensStatus) el.planTokensStatus.textContent = 'Saving...';
|
|
const rows = el.planTokensTable.querySelectorAll('input[data-plan]');
|
|
const payload = {};
|
|
rows.forEach((input) => {
|
|
const plan = input.dataset.plan;
|
|
const num = input.value ? Number(input.value) : 0;
|
|
payload[plan] = Number.isFinite(num) ? Math.max(0, Math.round(num)) : 0;
|
|
});
|
|
try {
|
|
const res = await api('/api/admin/plan-tokens', { method: 'POST', body: JSON.stringify({ limits: payload }) });
|
|
state.planTokens = res.limits || payload;
|
|
renderPlanTokens();
|
|
if (el.planTokensStatus) el.planTokensStatus.textContent = 'Saved';
|
|
setTimeout(() => { if (el.planTokensStatus) el.planTokensStatus.textContent = ''; }, 1400);
|
|
} catch (err) {
|
|
if (el.planTokensStatus) el.planTokensStatus.textContent = err.message || String(err);
|
|
} finally {
|
|
if (el.savePlanTokens) el.savePlanTokens.disabled = false;
|
|
}
|
|
}
|
|
|
|
// --- Token rates UI ---
|
|
async function loadTokenRates() {
|
|
if (!el.tokenRateUsd && !el.tokenRateGbp && !el.tokenRateEur) return;
|
|
try {
|
|
const data = await api('/api/admin/token-rates');
|
|
state.tokenRates = data.rates || {};
|
|
renderTokenRates();
|
|
} catch (err) {
|
|
if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = String(err.message || err);
|
|
}
|
|
}
|
|
|
|
function renderTokenRates() {
|
|
if (el.tokenRateUsd) el.tokenRateUsd.value = String(state.tokenRates?.usd ?? '');
|
|
if (el.tokenRateGbp) el.tokenRateGbp.value = String(state.tokenRates?.gbp ?? '');
|
|
if (el.tokenRateEur) el.tokenRateEur.value = String(state.tokenRates?.eur ?? '');
|
|
}
|
|
|
|
async function saveTokenRates() {
|
|
if (!el.saveTokenRates) return;
|
|
if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = 'Saving...';
|
|
el.saveTokenRates.disabled = true;
|
|
|
|
const rates = {
|
|
usd: Number(el.tokenRateUsd?.value || 0),
|
|
gbp: Number(el.tokenRateGbp?.value || 0),
|
|
eur: Number(el.tokenRateEur?.value || 0),
|
|
};
|
|
|
|
Object.keys(rates).forEach((key) => {
|
|
const value = rates[key];
|
|
rates[key] = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
|
});
|
|
|
|
try {
|
|
const res = await api('/api/admin/token-rates', { method: 'POST', body: JSON.stringify({ rates }) });
|
|
state.tokenRates = res.rates || rates;
|
|
renderTokenRates();
|
|
if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = 'Saved';
|
|
setTimeout(() => { if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = ''; }, 1400);
|
|
} catch (err) {
|
|
if (el.tokenRatesStatus) el.tokenRatesStatus.textContent = err.message || String(err);
|
|
} finally {
|
|
el.saveTokenRates.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function loadPlanProviderSettings() {
|
|
if (!el.planProviderForm && !el.autoModelForm && !el.planPriorityList) return;
|
|
try {
|
|
const data = await api('/api/admin/plan-settings');
|
|
state.planSettings = {
|
|
provider: 'openrouter',
|
|
freePlanModel: '',
|
|
planningChain: [],
|
|
...(data || {}),
|
|
};
|
|
if (el.planProvider) el.planProvider.value = state.planSettings.provider || 'openrouter';
|
|
populateAutoModelOptions(state.planSettings.freePlanModel || '');
|
|
populateFreePlanModelOptions(state.planSettings.freePlanModel || '');
|
|
renderPlanPriority();
|
|
} catch (err) {
|
|
if (el.planProviderForm) setPlanProviderStatus(err.message, true);
|
|
if (el.autoModelForm) setAutoModelStatus(err.message, true);
|
|
if (el.planPriorityList) setPlanChainStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
function populateAutoModelOptions(selectedValue) {
|
|
if (!el.autoModelSelect) return;
|
|
|
|
const normalizeTier = (tier) => {
|
|
const normalized = String(tier || 'free').trim().toLowerCase();
|
|
return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free';
|
|
};
|
|
|
|
// Use publicModels if available, fallback to configured for legacy support
|
|
const configured = state.publicModels.length > 0 ? state.publicModels : (Array.isArray(state.configured) ? state.configured : []);
|
|
const configuredByName = new Map();
|
|
configured.forEach((m) => {
|
|
const name = (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '';
|
|
if (name) configuredByName.set(name, m);
|
|
});
|
|
|
|
const current = typeof selectedValue === 'string' ? selectedValue : el.autoModelSelect.value;
|
|
|
|
el.autoModelSelect.innerHTML = '';
|
|
const auto = document.createElement('option');
|
|
auto.value = '';
|
|
auto.textContent = 'Auto (first free model)';
|
|
el.autoModelSelect.appendChild(auto);
|
|
|
|
const freeModels = configured
|
|
.filter((m) => normalizeTier(m.tier) === 'free')
|
|
.map((m) => ({
|
|
name: (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '',
|
|
label: (m && (m.label || m.name || m.id)) ? String(m.label || m.name || m.id).trim() : '',
|
|
}))
|
|
.filter((m) => m.name);
|
|
|
|
const freeGroup = document.createElement('optgroup');
|
|
freeGroup.label = 'Free-tier models';
|
|
|
|
const freeNames = new Set();
|
|
freeModels
|
|
.sort((a, b) => a.label.localeCompare(b.label))
|
|
.forEach((m) => {
|
|
freeNames.add(m.name);
|
|
const opt = document.createElement('option');
|
|
opt.value = m.name;
|
|
opt.textContent = m.label || m.name;
|
|
freeGroup.appendChild(opt);
|
|
});
|
|
|
|
const discoveredNames = getAvailableModelNames()
|
|
.map((name) => String(name || '').trim())
|
|
.filter(Boolean)
|
|
.filter((name, idx, arr) => arr.indexOf(name) === idx)
|
|
.filter((name) => !freeNames.has(name));
|
|
|
|
const discoveredGroup = document.createElement('optgroup');
|
|
discoveredGroup.label = 'Other discovered models';
|
|
|
|
discoveredNames
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.forEach((name) => {
|
|
const configuredModel = configuredByName.get(name);
|
|
const tier = configuredModel ? normalizeTier(configuredModel.tier) : null;
|
|
const opt = document.createElement('option');
|
|
opt.value = name;
|
|
if (!configuredModel) {
|
|
opt.textContent = `${name} (unpublished)`;
|
|
} else {
|
|
opt.textContent = `${name} (${tier.toUpperCase()})`;
|
|
if (tier !== 'free') opt.disabled = true;
|
|
}
|
|
discoveredGroup.appendChild(opt);
|
|
});
|
|
|
|
const hasFree = freeGroup.children.length > 0;
|
|
const hasDiscovered = discoveredGroup.children.length > 0;
|
|
|
|
if (hasFree) {
|
|
el.autoModelSelect.appendChild(freeGroup);
|
|
}
|
|
|
|
if (hasDiscovered) {
|
|
el.autoModelSelect.appendChild(discoveredGroup);
|
|
}
|
|
|
|
if (!hasFree && !hasDiscovered) {
|
|
const note = document.createElement('option');
|
|
note.value = '__none__';
|
|
note.textContent = '(No models discovered yet)';
|
|
note.disabled = true;
|
|
el.autoModelSelect.appendChild(note);
|
|
}
|
|
|
|
const currentName = (current || '').trim();
|
|
if (currentName && !Array.from(el.autoModelSelect.options).some((opt) => opt.value === currentName)) {
|
|
const orphan = document.createElement('option');
|
|
orphan.value = currentName;
|
|
orphan.textContent = `${currentName} (current selection)`;
|
|
el.autoModelSelect.appendChild(orphan);
|
|
}
|
|
|
|
el.autoModelSelect.value = currentName;
|
|
}
|
|
|
|
function populateFreePlanModelOptions(selectedValue) {
|
|
if (!el.freePlanModel) return;
|
|
const current = selectedValue || el.freePlanModel.value;
|
|
el.freePlanModel.innerHTML = '';
|
|
const auto = document.createElement('option');
|
|
auto.value = '';
|
|
auto.textContent = 'Auto (use default)';
|
|
el.freePlanModel.appendChild(auto);
|
|
(state.configured || []).forEach((m) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = m.name || m.id || '';
|
|
opt.textContent = m.label || m.name || m.id || '';
|
|
el.freePlanModel.appendChild(opt);
|
|
});
|
|
if (current !== undefined && current !== null) {
|
|
el.freePlanModel.value = current;
|
|
}
|
|
}
|
|
|
|
function populateOpencodeBackupOptions(selectedValue) {
|
|
console.log('populateOpencodeBackupOptions called with:', selectedValue);
|
|
if (!el.opencodeBackup) {
|
|
console.log('el.opencodeBackup is null, returning early');
|
|
return;
|
|
}
|
|
console.log('el.opencodeBackup found, populating...');
|
|
const current = selectedValue || el.opencodeBackup.value;
|
|
el.opencodeBackup.innerHTML = '';
|
|
|
|
const allModels = new Set();
|
|
(state.available || []).forEach((m) => {
|
|
const name = m.name || m.id || m;
|
|
if (name) allModels.add(name);
|
|
});
|
|
(state.configured || []).forEach((m) => {
|
|
if (m.name) allModels.add(m.name);
|
|
});
|
|
Object.values(state.providerModels || {}).forEach((arr) => {
|
|
(arr || []).forEach((name) => { if (name) allModels.add(name); });
|
|
});
|
|
|
|
console.log('Found models:', Array.from(allModels));
|
|
const sorted = Array.from(allModels).filter(Boolean).sort((a, b) => a.localeCompare(b));
|
|
|
|
const none = document.createElement('option');
|
|
none.value = '';
|
|
none.textContent = 'None (no backup)';
|
|
el.opencodeBackup.appendChild(none);
|
|
|
|
sorted.forEach((name) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = name;
|
|
opt.textContent = name;
|
|
el.opencodeBackup.appendChild(opt);
|
|
});
|
|
|
|
if (current) el.opencodeBackup.value = current;
|
|
console.log('Dropdown populated with', sorted.length + 1, 'options');
|
|
}
|
|
|
|
function formatDisplayDate(value) {
|
|
if (!value) return '—';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '—';
|
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
function renderAccounts() {
|
|
if (!el.accountsTable) return;
|
|
el.accountsTable.innerHTML = '';
|
|
if (el.accountsCount) el.accountsCount.textContent = `${state.accounts.length} accounts`;
|
|
if (!state.accounts.length) {
|
|
const row = document.createElement('tr');
|
|
const cell = document.createElement('td');
|
|
cell.colSpan = 9;
|
|
cell.textContent = 'No accounts found.';
|
|
cell.className = 'muted';
|
|
cell.style.padding = '12px';
|
|
row.appendChild(cell);
|
|
el.accountsTable.appendChild(row);
|
|
return;
|
|
}
|
|
state.accounts.forEach((acct) => {
|
|
const row = document.createElement('tr');
|
|
row.style.borderBottom = '1px solid var(--border)';
|
|
[
|
|
acct.email || 'Unknown',
|
|
acct.plan || 'starter',
|
|
acct.billingStatus || 'active',
|
|
acct.billingEmail || '—',
|
|
formatDisplayDate(acct.subscriptionRenewsAt),
|
|
formatDisplayDate(acct.createdAt),
|
|
formatDisplayDate(acct.lastLoginAt),
|
|
].forEach((value) => {
|
|
const cell = document.createElement('td');
|
|
cell.style.padding = '10px 8px';
|
|
cell.textContent = value;
|
|
row.appendChild(cell);
|
|
});
|
|
|
|
const tokenUsage = acct.tokenUsage || {};
|
|
const tokensUsed = tokenUsage.used || 0;
|
|
const tokensLimit = tokenUsage.limit || 0;
|
|
const tokenOverride = tokenUsage.tokenOverride;
|
|
const tokensDisplay = `${tokensUsed.toLocaleString()} / ${tokensLimit.toLocaleString()}${tokenOverride !== null && tokenOverride !== undefined ? ' (override)' : ''}`;
|
|
|
|
const tokenCell = document.createElement('td');
|
|
tokenCell.style.padding = '10px 8px';
|
|
tokenCell.textContent = tokensDisplay;
|
|
tokenCell.title = tokenOverride !== null && tokenOverride !== undefined ? `Manual override: ${tokenOverride.toLocaleString()} tokens` : 'Plan-based limit';
|
|
row.appendChild(tokenCell);
|
|
|
|
const actionsCell = document.createElement('td');
|
|
actionsCell.style.padding = '10px 8px';
|
|
actionsCell.style.display = 'flex';
|
|
actionsCell.style.gap = '8px';
|
|
const changeBtn = document.createElement('button');
|
|
changeBtn.className = 'ghost';
|
|
changeBtn.textContent = 'Change Plan';
|
|
changeBtn.title = 'Change user plan without payment';
|
|
changeBtn.addEventListener('click', () => changePlan(acct));
|
|
actionsCell.appendChild(changeBtn);
|
|
|
|
const tokensBtn = document.createElement('button');
|
|
tokensBtn.className = 'ghost';
|
|
tokensBtn.textContent = 'Set Tokens';
|
|
tokensBtn.title = 'Manually set token limit for this user';
|
|
tokensBtn.addEventListener('click', () => setTokens(acct));
|
|
actionsCell.appendChild(tokensBtn);
|
|
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'danger';
|
|
deleteBtn.textContent = 'Delete';
|
|
deleteBtn.title = 'Permanently delete user and all data';
|
|
deleteBtn.addEventListener('click', () => deleteUser(acct));
|
|
actionsCell.appendChild(deleteBtn);
|
|
|
|
row.appendChild(actionsCell);
|
|
|
|
el.accountsTable.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function changePlan(acct) {
|
|
const plans = ['hobby', 'starter', 'business', 'enterprise'];
|
|
const currentPlan = acct.plan || 'hobby';
|
|
const nextPlan = prompt(`Change plan for ${acct.email}\nCurrent plan: ${currentPlan}\n\nEnter new plan (hobby, starter, business, enterprise):`, currentPlan);
|
|
|
|
if (nextPlan === null) return;
|
|
const normalized = nextPlan.trim().toLowerCase();
|
|
if (!plans.includes(normalized)) {
|
|
alert('Invalid plan. Please enter hobby, starter, business, or enterprise.');
|
|
return;
|
|
}
|
|
if (normalized === currentPlan) return;
|
|
|
|
if (!confirm(`Are you sure you want to change ${acct.email}'s plan to ${normalized.toUpperCase()}? This will take effect immediately without charging them.`)) {
|
|
return;
|
|
}
|
|
|
|
setStatus(`Updating plan for ${acct.email}...`);
|
|
try {
|
|
await api('/api/admin/accounts/plan', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ userId: acct.id, plan: normalized })
|
|
});
|
|
setStatus('Plan updated successfully');
|
|
await loadAccounts();
|
|
setTimeout(() => setStatus(''), 3000);
|
|
} catch (err) {
|
|
setStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
async function deleteUser(acct) {
|
|
const confirmation = window.confirm(
|
|
`Are you absolutely sure you want to permanently delete ${acct.email}?\n\n` +
|
|
`This will:\n` +
|
|
`• Delete the user account permanently\n` +
|
|
`• Remove all their apps/sessions\n` +
|
|
`• Delete all their workspace data\n` +
|
|
`• This action CANNOT be undone!\n\n` +
|
|
`Type DELETE to confirm:`
|
|
);
|
|
|
|
if (!confirmation) return;
|
|
|
|
const confirmationText = window.prompt(
|
|
'This action cannot be undone. Type DELETE to confirm permanently deleting this user:'
|
|
);
|
|
|
|
if (confirmationText !== 'DELETE') {
|
|
alert('Deletion cancelled. You must type DELETE to confirm.');
|
|
return;
|
|
}
|
|
|
|
setStatus(`Permanently deleting ${acct.email}...`);
|
|
try {
|
|
await api('/api/admin/accounts', {
|
|
method: 'DELETE',
|
|
body: JSON.stringify({ userId: acct.id })
|
|
});
|
|
setStatus('User permanently deleted');
|
|
await loadAccounts();
|
|
setTimeout(() => setStatus(''), 3000);
|
|
} catch (err) {
|
|
setStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
async function setTokens(acct) {
|
|
const tokenUsage = acct.tokenUsage || {};
|
|
const currentLimit = tokenUsage.limit || 0;
|
|
const currentUsed = tokenUsage.used || 0;
|
|
const currentRemaining = tokenUsage.remaining || 0;
|
|
const hasOverride = tokenUsage.tokenOverride !== null && tokenUsage.tokenOverride !== undefined;
|
|
const currentOverride = hasOverride ? tokenUsage.tokenOverride : '';
|
|
|
|
const modeMessage = `Manage tokens for ${acct.email}\n\n` +
|
|
`Current Status:\n` +
|
|
` Plan: ${acct.plan || 'starter'}\n` +
|
|
` Limit: ${currentLimit.toLocaleString()} tokens\n` +
|
|
` Used: ${currentUsed.toLocaleString()} tokens\n` +
|
|
` Remaining: ${currentRemaining.toLocaleString()} tokens\n` +
|
|
`${hasOverride ? ` Override: ${currentOverride.toLocaleString()} tokens\n` : ''}\n` +
|
|
`Choose mode:\n` +
|
|
` 1 = Set LIMIT (override plan limit)\n` +
|
|
` 2 = Set USED (directly set tokens consumed)\n` +
|
|
` 3 = Set REMAINING (set tokens left to use)`;
|
|
|
|
const modeInput = prompt(modeMessage, '1');
|
|
if (modeInput === null) return;
|
|
|
|
const mode = parseInt(modeInput.trim(), 10);
|
|
if (![1, 2, 3].includes(mode)) {
|
|
alert('Invalid mode. Please enter 1, 2, or 3.');
|
|
return;
|
|
}
|
|
|
|
let promptText = '';
|
|
let defaultValue = '';
|
|
let confirmText = '';
|
|
|
|
if (mode === 1) {
|
|
promptText = `Set token LIMIT for ${acct.email}\n\n` +
|
|
`Current limit: ${currentLimit.toLocaleString()} tokens\n` +
|
|
`${hasOverride ? `Current override: ${currentOverride.toLocaleString()}\n` : ''}\n` +
|
|
`Enter new limit (0 to remove override):`;
|
|
defaultValue = currentOverride;
|
|
} else if (mode === 2) {
|
|
promptText = `Set tokens USED for ${acct.email}\n\n` +
|
|
`Current used: ${currentUsed.toLocaleString()} tokens\n` +
|
|
`Limit: ${currentLimit.toLocaleString()} tokens\n\n` +
|
|
`Enter new usage amount:`;
|
|
defaultValue = currentUsed;
|
|
} else {
|
|
promptText = `Set tokens REMAINING for ${acct.email}\n\n` +
|
|
`Current remaining: ${currentRemaining.toLocaleString()} tokens\n` +
|
|
`Limit: ${currentLimit.toLocaleString()} tokens\n\n` +
|
|
`Enter remaining tokens (will calculate usage):`;
|
|
defaultValue = currentRemaining;
|
|
}
|
|
|
|
const tokenInput = prompt(promptText, String(defaultValue));
|
|
if (tokenInput === null) return;
|
|
|
|
const tokens = parseInt(tokenInput.trim(), 10);
|
|
if (isNaN(tokens) || tokens < 0) {
|
|
alert('Invalid token amount. Please enter a non-negative number.');
|
|
return;
|
|
}
|
|
|
|
if (mode === 1) {
|
|
confirmText = `Set ${acct.email}'s token LIMIT to ${tokens.toLocaleString()}?`;
|
|
} else if (mode === 2) {
|
|
confirmText = `Set ${acct.email}'s tokens USED to ${tokens.toLocaleString()}?`;
|
|
} else {
|
|
confirmText = `Set ${acct.email}'s tokens REMAINING to ${tokens.toLocaleString()}?`;
|
|
}
|
|
|
|
if (!confirm(confirmText)) {
|
|
return;
|
|
}
|
|
|
|
const modeNames = { 1: 'limit', 2: 'usage', 3: 'remaining' };
|
|
setStatus(`Updating ${modeNames[mode]} for ${acct.email}...`);
|
|
try {
|
|
await api('/api/admin/accounts/tokens', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ userId: acct.id, tokens: tokens, mode: modeNames[mode] })
|
|
});
|
|
setStatus(`Token ${modeNames[mode]} updated successfully`);
|
|
await loadAccounts();
|
|
setTimeout(() => setStatus(''), 3000);
|
|
} catch (err) {
|
|
setStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
async function loadAccounts() {
|
|
if (!el.accountsTable) return;
|
|
setStatus('Loading accounts...');
|
|
try {
|
|
const data = await api('/api/admin/accounts');
|
|
state.accounts = data.accounts || [];
|
|
renderAccounts();
|
|
setStatus('');
|
|
} catch (err) {
|
|
setStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
function renderAffiliates() {
|
|
if (!el.affiliatesTable) return;
|
|
el.affiliatesTable.innerHTML = '';
|
|
if (el.affiliatesCount) el.affiliatesCount.textContent = `${state.affiliates.length} affiliates`;
|
|
if (!state.affiliates.length) {
|
|
const row = document.createElement('tr');
|
|
const cell = document.createElement('td');
|
|
cell.colSpan = 8;
|
|
cell.textContent = 'No affiliate accounts found.';
|
|
cell.className = 'muted';
|
|
cell.style.padding = '12px';
|
|
row.appendChild(cell);
|
|
el.affiliatesTable.appendChild(row);
|
|
return;
|
|
}
|
|
state.affiliates.forEach((aff) => {
|
|
const row = document.createElement('tr');
|
|
row.style.borderBottom = '1px solid var(--border)';
|
|
|
|
const emailCell = document.createElement('td');
|
|
emailCell.style.padding = '10px 8px';
|
|
emailCell.textContent = aff.email || 'Unknown';
|
|
row.appendChild(emailCell);
|
|
|
|
const nameCell = document.createElement('td');
|
|
nameCell.style.padding = '10px 8px';
|
|
nameCell.textContent = aff.name || '—';
|
|
row.appendChild(nameCell);
|
|
|
|
const commissionCell = document.createElement('td');
|
|
commissionCell.style.padding = '10px 8px';
|
|
commissionCell.textContent = `${((aff.commissionRate ?? 0.075) * 100).toFixed(1)}%`;
|
|
row.appendChild(commissionCell);
|
|
|
|
const earningsCell = document.createElement('td');
|
|
earningsCell.style.padding = '10px 8px';
|
|
const earningsTotal = aff.earnings?.total || 0;
|
|
earningsCell.textContent = `$${earningsTotal.toFixed(2)}`;
|
|
row.appendChild(earningsCell);
|
|
|
|
const linksCell = document.createElement('td');
|
|
linksCell.style.padding = '10px 8px';
|
|
const linksCount = Array.isArray(aff.trackingLinks) ? aff.trackingLinks.length : 0;
|
|
linksCell.textContent = linksCount > 0 ? `${linksCount} link${linksCount > 1 ? 's' : ''}` : '—';
|
|
row.appendChild(linksCell);
|
|
|
|
const createdCell = document.createElement('td');
|
|
createdCell.style.padding = '10px 8px';
|
|
createdCell.textContent = formatDisplayDate(aff.createdAt);
|
|
row.appendChild(createdCell);
|
|
|
|
const lastLoginCell = document.createElement('td');
|
|
lastLoginCell.style.padding = '10px 8px';
|
|
lastLoginCell.textContent = formatDisplayDate(aff.lastLoginAt);
|
|
row.appendChild(lastLoginCell);
|
|
|
|
const actionsCell = document.createElement('td');
|
|
actionsCell.style.padding = '10px 8px';
|
|
actionsCell.style.display = 'flex';
|
|
actionsCell.style.gap = '8px';
|
|
|
|
const viewLinksBtn = document.createElement('button');
|
|
viewLinksBtn.className = 'ghost';
|
|
viewLinksBtn.textContent = 'View Links';
|
|
viewLinksBtn.title = 'View tracking links';
|
|
viewLinksBtn.addEventListener('click', () => viewAffiliateLinks(aff));
|
|
actionsCell.appendChild(viewLinksBtn);
|
|
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'danger';
|
|
deleteBtn.textContent = 'Delete';
|
|
deleteBtn.title = 'Permanently delete affiliate account';
|
|
deleteBtn.addEventListener('click', () => deleteAffiliate(aff));
|
|
actionsCell.appendChild(deleteBtn);
|
|
|
|
row.appendChild(actionsCell);
|
|
|
|
el.affiliatesTable.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function viewAffiliateLinks(aff) {
|
|
if (!aff.trackingLinks || !aff.trackingLinks.length) {
|
|
alert('No tracking links for this affiliate.');
|
|
return;
|
|
}
|
|
const linksList = aff.trackingLinks.map(l => `${l.code} → ${l.targetPath || '/'}`).join('\n');
|
|
alert(`Tracking links for ${aff.email}:\n\n${linksList}`);
|
|
}
|
|
|
|
async function deleteAffiliate(aff) {
|
|
const confirmation = window.confirm(
|
|
`Are you absolutely sure you want to permanently delete affiliate ${aff.email}?\n\n` +
|
|
`This will:\n` +
|
|
`• Delete the affiliate account permanently\n` +
|
|
`• All tracking links will stop working\n` +
|
|
`• Commission tracking for past referrals will remain\n` +
|
|
`• This action CANNOT be undone!\n\n` +
|
|
`Type DELETE to confirm:`
|
|
);
|
|
|
|
if (!confirmation) return;
|
|
|
|
const confirmationText = window.prompt(
|
|
'This action cannot be undone. Type DELETE to confirm permanently deleting this affiliate:'
|
|
);
|
|
|
|
if (confirmationText !== 'DELETE') {
|
|
alert('Deletion cancelled. You must type DELETE to confirm.');
|
|
return;
|
|
}
|
|
|
|
setStatus(`Permanently deleting affiliate ${aff.email}...`);
|
|
try {
|
|
await api('/api/admin/affiliates', {
|
|
method: 'DELETE',
|
|
body: JSON.stringify({ affiliateId: aff.id })
|
|
});
|
|
setStatus('Affiliate permanently deleted');
|
|
await loadAffiliates();
|
|
setTimeout(() => setStatus(''), 3000);
|
|
} catch (err) {
|
|
setStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
async function loadAffiliates() {
|
|
if (!el.affiliatesTable) return;
|
|
setStatus('Loading affiliates...');
|
|
try {
|
|
const data = await api('/api/admin/affiliates');
|
|
state.affiliates = data.affiliates || [];
|
|
renderAffiliates();
|
|
setStatus('');
|
|
} catch (err) {
|
|
setStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
async function loadWithdrawals() {
|
|
if (!el.withdrawalsTable) return;
|
|
setStatus('Loading withdrawals...');
|
|
try {
|
|
const data = await api('/api/admin/withdrawals');
|
|
state.withdrawals = data.withdrawals || [];
|
|
renderWithdrawals();
|
|
setStatus('');
|
|
} catch (err) {
|
|
setStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
function renderWithdrawals() {
|
|
if (!el.withdrawalsTable) return;
|
|
el.withdrawalsTable.innerHTML = '';
|
|
if (el.withdrawalsCount) el.withdrawalsCount.textContent = `${state.withdrawals.length} requests`;
|
|
if (!state.withdrawals.length) {
|
|
const row = document.createElement('tr');
|
|
const cell = document.createElement('td');
|
|
cell.colSpan = 7;
|
|
cell.textContent = 'No withdrawal requests found.';
|
|
cell.className = 'muted';
|
|
cell.style.padding = '12px';
|
|
row.appendChild(cell);
|
|
el.withdrawalsTable.appendChild(row);
|
|
return;
|
|
}
|
|
state.withdrawals.forEach((w) => {
|
|
const row = document.createElement('tr');
|
|
row.style.borderBottom = '1px solid var(--border)';
|
|
|
|
const dateCell = document.createElement('td');
|
|
dateCell.style.padding = '10px 8px';
|
|
dateCell.textContent = formatDisplayDate(w.createdAt);
|
|
row.appendChild(dateCell);
|
|
|
|
const affiliateCell = document.createElement('td');
|
|
affiliateCell.style.padding = '10px 8px';
|
|
affiliateCell.textContent = w.affiliateEmail || 'Unknown';
|
|
row.appendChild(affiliateCell);
|
|
|
|
const paypalCell = document.createElement('td');
|
|
paypalCell.style.padding = '10px 8px';
|
|
paypalCell.textContent = w.paypalEmail || '—';
|
|
row.appendChild(paypalCell);
|
|
|
|
const amountCell = document.createElement('td');
|
|
amountCell.style.padding = '10px 8px';
|
|
amountCell.textContent = `$${Number(w.amount || 0).toFixed(2)}`;
|
|
row.appendChild(amountCell);
|
|
|
|
const currencyCell = document.createElement('td');
|
|
currencyCell.style.padding = '10px 8px';
|
|
currencyCell.textContent = w.currency || 'USD';
|
|
row.appendChild(currencyCell);
|
|
|
|
const statusCell = document.createElement('td');
|
|
statusCell.style.padding = '10px 8px';
|
|
const statusBadge = document.createElement('span');
|
|
statusBadge.className = 'pill';
|
|
statusBadge.textContent = w.status || 'pending';
|
|
statusBadge.style.background = w.status === 'done' ? 'var(--success)' : 'var(--warning)';
|
|
statusCell.appendChild(statusBadge);
|
|
row.appendChild(statusCell);
|
|
|
|
const actionsCell = document.createElement('td');
|
|
actionsCell.style.padding = '10px 8px';
|
|
actionsCell.style.display = 'flex';
|
|
actionsCell.style.gap = '8px';
|
|
|
|
if (w.status === 'pending') {
|
|
const markDoneBtn = document.createElement('button');
|
|
markDoneBtn.className = 'ghost';
|
|
markDoneBtn.textContent = 'Mark Done';
|
|
markDoneBtn.title = 'Mark withdrawal as completed';
|
|
markDoneBtn.addEventListener('click', () => updateWithdrawalStatus(w, 'done'));
|
|
actionsCell.appendChild(markDoneBtn);
|
|
}
|
|
|
|
const detailsBtn = document.createElement('button');
|
|
detailsBtn.className = 'ghost';
|
|
detailsBtn.textContent = 'Details';
|
|
detailsBtn.title = 'View withdrawal details';
|
|
detailsBtn.addEventListener('click', () => viewWithdrawalDetails(w));
|
|
actionsCell.appendChild(detailsBtn);
|
|
|
|
row.appendChild(actionsCell);
|
|
|
|
el.withdrawalsTable.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function viewWithdrawalDetails(w) {
|
|
alert(`Withdrawal Details:\n\n` +
|
|
`Date: ${new Date(w.createdAt).toLocaleString()}\n` +
|
|
`Affiliate: ${w.affiliateEmail || 'Unknown'}\n` +
|
|
`PayPal Email: ${w.paypalEmail || '—'}\n` +
|
|
`Amount: $${Number(w.amount || 0).toFixed(2)}\n` +
|
|
`Currency: ${w.currency || 'USD'}\n` +
|
|
`Status: ${w.status || 'pending'}`);
|
|
}
|
|
|
|
async function updateWithdrawalStatus(withdrawal, newStatus) {
|
|
setStatus('Updating withdrawal status...');
|
|
try {
|
|
await api('/api/admin/withdrawals', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ withdrawalId: withdrawal.id, status: newStatus })
|
|
});
|
|
setStatus(`Withdrawal marked as ${newStatus}`);
|
|
await loadWithdrawals();
|
|
setTimeout(() => setStatus(''), 3000);
|
|
} catch (err) {
|
|
setStatus(err.message, true);
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
console.log('init() called');
|
|
try {
|
|
const loaders = [
|
|
() => ((el.availableModels || el.planPriorityList) ? loadAvailable() : null),
|
|
() => ((el.iconSelect || el.iconList) ? loadIcons() : null),
|
|
() => (el.configuredList ? loadConfigured() : null),
|
|
() => (el.orForm ? loadOpenRouterSettings() : null),
|
|
() => (el.mistralForm ? loadMistralSettings() : null),
|
|
() => ((el.autoModelForm || el.planProviderForm || el.planPriorityList) ? loadPlanProviderSettings() : null),
|
|
() => (el.accountsTable ? loadAccounts() : null),
|
|
() => (el.affiliatesTable ? loadAffiliates() : null),
|
|
() => (el.withdrawalsTable ? loadWithdrawals() : null),
|
|
() => (el.planTokensTable ? loadPlanTokens() : null),
|
|
() => ((el.tokenRateUsd || el.tokenRateGbp || el.tokenRateEur) ? loadTokenRates() : null),
|
|
() => ((el.providerUsage || el.providerLimitForm) ? loadProviderLimits() : null),
|
|
() => (el.externalTestingConfig ? loadExternalTestingStatus() : null),
|
|
];
|
|
await Promise.all(loaders.map((fn) => fn()).filter(Boolean));
|
|
// 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.opencodeBackupForm) {
|
|
el.opencodeBackupForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const opencodeBackupModel = el.opencodeBackup ? el.opencodeBackup.value.trim() : '';
|
|
|
|
setOpencodeBackupStatus('Saving...');
|
|
try {
|
|
const res = await api('/api/admin/provider-limits', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ provider: 'opencode', scope: 'provider', model: '', tokensPerMinute: '', tokensPerDay: '', requestsPerMinute: '', requestsPerDay: '', opencodeBackupModel }),
|
|
});
|
|
// update local state and refresh dropdowns
|
|
state.opencodeBackupModel = res.opencodeBackupModel || opencodeBackupModel || '';
|
|
populateOpencodeBackupOptions(state.opencodeBackupModel);
|
|
setOpencodeBackupStatus('Saved');
|
|
setTimeout(() => setOpencodeBackupStatus(''), 3000);
|
|
} catch (err) {
|
|
setOpencodeBackupStatus(err.message, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (el.autoModelForm) {
|
|
el.autoModelForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const freePlanModel = el.autoModelSelect ? el.autoModelSelect.value.trim() : '';
|
|
|
|
setAutoModelStatus('Saving...');
|
|
try {
|
|
await api('/api/admin/plan-settings', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ freePlanModel }),
|
|
});
|
|
setAutoModelStatus('Saved! Free plan users will use this model.');
|
|
setTimeout(() => setAutoModelStatus(''), 3000);
|
|
} catch (err) {
|
|
setAutoModelStatus(err.message, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (el.planProviderForm) {
|
|
el.planProviderForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const provider = el.planProvider.value.trim();
|
|
|
|
if (!provider || !PLANNING_PROVIDERS.includes(provider)) {
|
|
setPlanProviderStatus('Invalid provider selected.', true);
|
|
return;
|
|
}
|
|
|
|
setPlanProviderStatus('Saving...');
|
|
try {
|
|
await api('/api/admin/plan-settings', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ provider }),
|
|
});
|
|
setPlanProviderStatus('Saved');
|
|
setTimeout(() => setPlanProviderStatus(''), 3000);
|
|
} catch (err) {
|
|
setPlanProviderStatus(err.message, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (el.addPlanRow) {
|
|
el.addPlanRow.addEventListener('click', async () => {
|
|
const current = normalizePlanChainLocal(state.planSettings?.planningChain || []);
|
|
const next = [...current, { provider: state.planSettings?.provider || 'openrouter', model: '' }];
|
|
await persistPlanChain(next);
|
|
});
|
|
}
|
|
|
|
if (el.providerLimitForm) {
|
|
el.providerLimitForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const provider = el.limitProvider.value;
|
|
const scope = el.limitScope.value;
|
|
const payload = {
|
|
provider,
|
|
scope,
|
|
model: (pageType === 'plan' && el.limitModelInput) ? el.limitModelInput.value.trim() : el.limitModel.value.trim(),
|
|
tokensPerMinute: Number(el.limitTpm.value || 0),
|
|
tokensPerHour: Number(el.limitTph.value || 0),
|
|
tokensPerDay: Number(el.limitTpd.value || 0),
|
|
requestsPerMinute: Number(el.limitRpm.value || 0),
|
|
requestsPerHour: Number(el.limitRph.value || 0),
|
|
requestsPerDay: Number(el.limitRpd.value || 0),
|
|
opencodeBackupModel: el.limitBackup ? el.limitBackup.value.trim() : '',
|
|
};
|
|
setProviderLimitStatus('Saving...');
|
|
try {
|
|
await api('/api/admin/provider-limits', { method: 'POST', body: JSON.stringify(payload) });
|
|
setProviderLimitStatus('Saved');
|
|
await loadProviderLimits();
|
|
setTimeout(() => setProviderLimitStatus(''), 3000);
|
|
} catch (err) {
|
|
setProviderLimitStatus(err.message, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (el.limitProvider) {
|
|
el.limitProvider.addEventListener('change', () => {
|
|
populateLimitForm(el.limitProvider.value, el.limitScope ? el.limitScope.value : 'provider');
|
|
});
|
|
}
|
|
|
|
if (el.limitScope) {
|
|
el.limitScope.addEventListener('change', () => {
|
|
populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope.value);
|
|
});
|
|
}
|
|
|
|
if (el.limitModel) {
|
|
el.limitModel.addEventListener('change', () => {
|
|
populateLimitForm(el.limitProvider ? el.limitProvider.value : 'openrouter', el.limitScope ? el.limitScope.value : 'provider');
|
|
});
|
|
}
|
|
|
|
if (el.availableModels) {
|
|
el.availableModels.addEventListener('change', () => {
|
|
const selected = state.available.find((m) => (m.name || m.id || m) === el.availableModels.value);
|
|
if (selected && !el.displayLabel.value) el.displayLabel.value = selected.label || selected.name || '';
|
|
});
|
|
}
|
|
|
|
if (el.reloadAvailable) {
|
|
el.reloadAvailable.addEventListener('click', async () => {
|
|
setStatus('Refreshing available models...');
|
|
await loadAvailable();
|
|
setStatus('');
|
|
});
|
|
}
|
|
|
|
if (el.refresh) {
|
|
el.refresh.addEventListener('click', async () => {
|
|
setStatus('Refreshing...');
|
|
await init();
|
|
setStatus('');
|
|
});
|
|
}
|
|
|
|
// Plan tokens save button
|
|
if (el.savePlanTokens) {
|
|
el.savePlanTokens.addEventListener('click', async () => {
|
|
await savePlanTokens();
|
|
});
|
|
}
|
|
|
|
// Token rates save button
|
|
if (el.saveTokenRates) {
|
|
el.saveTokenRates.addEventListener('click', async () => {
|
|
await saveTokenRates();
|
|
});
|
|
}
|
|
|
|
// Cancel all messages button
|
|
if (el.cancelAllMessages) {
|
|
el.cancelAllMessages.addEventListener('click', async () => {
|
|
const confirmed = window.confirm('Are you sure you want to cancel ALL running and queued messages? This action cannot be undone.');
|
|
if (!confirmed) return;
|
|
|
|
el.cancelAllMessages.disabled = true;
|
|
if (el.cancelMessagesStatus) el.cancelMessagesStatus.textContent = 'Cancelling...';
|
|
|
|
try {
|
|
const data = await api('/api/admin/cancel-messages', { method: 'POST' });
|
|
if (el.cancelMessagesStatus) {
|
|
el.cancelMessagesStatus.textContent = `Cancelled ${data.totalCancelled} messages (${data.runningCancelled} running, ${data.queuedCancelled} queued) across ${data.sessionsAffected} sessions`;
|
|
el.cancelMessagesStatus.style.color = 'var(--accent)';
|
|
}
|
|
setTimeout(() => {
|
|
if (el.cancelMessagesStatus) {
|
|
el.cancelMessagesStatus.textContent = '';
|
|
el.cancelMessagesStatus.style.color = 'inherit';
|
|
}
|
|
}, 5000);
|
|
} catch (err) {
|
|
if (el.cancelMessagesStatus) {
|
|
el.cancelMessagesStatus.textContent = err.message || 'Failed to cancel messages';
|
|
el.cancelMessagesStatus.style.color = 'var(--danger)';
|
|
}
|
|
} finally {
|
|
el.cancelAllMessages.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (el.externalTestingRun) {
|
|
el.externalTestingRun.addEventListener('click', async () => {
|
|
el.externalTestingRun.disabled = true;
|
|
setExternalTestingStatus('Running self-test...');
|
|
try {
|
|
const data = await api('/api/admin/external-testing-self-test', { method: 'POST' });
|
|
renderExternalTestingOutput(data.result || null);
|
|
setExternalTestingStatus(data.result && data.result.ok ? 'Self-test passed.' : 'Self-test failed.', !data.result || !data.result.ok);
|
|
} catch (err) {
|
|
setExternalTestingStatus(err.message || 'Self-test failed.', true);
|
|
} finally {
|
|
el.externalTestingRun.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Ollama Test button handler
|
|
if (el.ollamaTestRun) {
|
|
el.ollamaTestRun.addEventListener('click', async () => {
|
|
el.ollamaTestRun.disabled = true;
|
|
setOllamaTestStatus('Running Ollama test...');
|
|
if (el.ollamaTestOutput) el.ollamaTestOutput.innerHTML = '';
|
|
|
|
try {
|
|
const data = await api('/api/admin/ollama-test', { method: 'POST' });
|
|
renderOllamaTestOutput(data);
|
|
|
|
if (data.ok) {
|
|
setOllamaTestStatus(`Test passed! Response time: ${data.duration}ms`);
|
|
} else {
|
|
setOllamaTestStatus(`Test failed: ${data.error?.message || 'Unknown error'}`, true);
|
|
}
|
|
} catch (err) {
|
|
setOllamaTestStatus(err.message || 'Test failed', true);
|
|
if (el.ollamaTestOutput) {
|
|
el.ollamaTestOutput.innerHTML = `<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();
|
|
}
|
|
})();
|