Fix model sections: separate Provider Models and Public-Facing Models

- Provider Models section: restored OpenCode integration with dropdown
- Public-Facing Models section: completely separate manual entry
- Provider Chain section: fallback chain with up/down buttons (unchanged)
- Added separate arrays: providerModels and publicModels
- Added reorder support for both provider and public models
- Updated server to handle providerModel type and reorder by type
This commit is contained in:
southseact-3d
2026-02-18 14:38:19 +00:00
parent 7e5dc8b62d
commit cc17079988
3 changed files with 596 additions and 81 deletions

View File

@@ -4,10 +4,11 @@
const pageType = document?.body?.dataset?.page || 'build';
console.log('Admin JS loaded, pageType:', pageType);
const state = {
available: [],
configured: [],
publicModels: [], // New unified structure - public facing models
providerChain: [], // New unified structure - provider fallback chain
available: [], // Models available from OpenCode
configured: [], // Legacy - provider models from OpenCode
providerModels: [], // Provider models (from OpenCode)
providerChain: [], // Unified fallback chain
publicModels: [], // Public-facing models (completely separate)
icons: [],
accounts: [],
affiliates: [],
@@ -17,12 +18,27 @@
providerUsage: [],
opencodeBackupModel: '',
providerOptions: [],
providerModels: {},
tokenRates: {},
};
const el = {
// New unified model elements
// Provider Models (with OpenCode integration)
providerModelForm: document.getElementById('provider-model-form'),
availableModels: document.getElementById('available-models'),
providerModelLabel: document.getElementById('provider-model-label'),
providerModelTier: document.getElementById('provider-model-tier'),
providerModelIcon: document.getElementById('provider-model-icon'),
providerModelMedia: document.getElementById('provider-model-media'),
providerModelStatus: document.getElementById('provider-model-status'),
reloadAvailable: document.getElementById('reload-available'),
// Provider Chain
providerChainForm: document.getElementById('provider-chain-form'),
chainProvider: document.getElementById('chain-provider'),
chainModel: document.getElementById('chain-model'),
providerChainStatus: document.getElementById('provider-chain-status'),
providerChainList: document.getElementById('provider-chain-list'),
providerChainCount: document.getElementById('provider-chain-count'),
// Public-Facing Models (completely separate)
publicModelForm: document.getElementById('public-model-form'),
publicModelName: document.getElementById('public-model-name'),
publicModelLabel: document.getElementById('public-model-label'),
@@ -30,14 +46,9 @@
publicModelIcon: document.getElementById('public-model-icon'),
publicModelMedia: document.getElementById('public-model-media'),
publicModelStatus: document.getElementById('public-model-status'),
providerChainForm: document.getElementById('provider-chain-form'),
chainProvider: document.getElementById('chain-provider'),
chainModel: document.getElementById('chain-model'),
providerChainStatus: document.getElementById('provider-chain-status'),
providerChainList: document.getElementById('provider-chain-list'),
providerChainCount: document.getElementById('provider-chain-count'),
publicModelsList: document.getElementById('public-models-list'),
publicModelsCount: document.getElementById('public-models-count'),
// Legacy elements
availableModels: document.getElementById('available-models'),
displayLabel: document.getElementById('display-label'),
modelTier: document.getElementById('model-tier'),
iconSelect: document.getElementById('icon-select'),
@@ -198,6 +209,12 @@
el.autoModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setProviderModelStatus(msg, isError = false) {
if (!el.providerModelStatus) return;
el.providerModelStatus.textContent = msg || '';
el.providerModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
}
function setPublicModelStatus(msg, isError = false) {
if (!el.publicModelStatus) return;
el.publicModelStatus.textContent = msg || '';
@@ -546,7 +563,22 @@
});
}
// Populate new public model icon select
// Populate provider model icon select
if (el.providerModelIcon) {
el.providerModelIcon.innerHTML = '';
const none = document.createElement('option');
none.value = '';
none.textContent = 'No icon';
el.providerModelIcon.appendChild(none);
state.icons.forEach((iconPath) => {
const opt = document.createElement('option');
opt.value = iconPath;
opt.textContent = iconPath.replace('/assets/', '');
el.providerModelIcon.appendChild(opt);
});
}
// Populate public model icon select
if (el.publicModelIcon) {
el.publicModelIcon.innerHTML = '';
const none = document.createElement('option');
@@ -588,19 +620,18 @@
}
}
// Simplified renderConfigured for new publicModels structure
function renderConfigured() {
// Render Provider Models (from OpenCode)
function renderProviderModels() {
if (!el.configuredList) return;
el.configuredList.innerHTML = '';
// Use publicModels if available, otherwise fall back to configured
const modelsToRender = state.publicModels.length > 0 ? state.publicModels : state.configured;
const modelsToRender = state.providerModels;
if (el.configuredCount) el.configuredCount.textContent = modelsToRender.length.toString();
if (!modelsToRender.length) {
const empty = document.createElement('div');
empty.className = 'muted';
empty.textContent = 'No public-facing models configured yet.';
empty.textContent = 'No provider models configured yet. Add models from OpenCode above.';
el.configuredList.appendChild(empty);
return;
}
@@ -677,7 +708,7 @@
const next = [...modelsToRender];
const [item] = next.splice(idx, 1);
next.splice(idx - 1, 0, item);
await persistPublicModelsOrder(next);
await persistProviderModelsOrder(next);
});
headerActions.appendChild(upBtn);
@@ -692,7 +723,7 @@
const next = [...modelsToRender];
const [item] = next.splice(idx, 1);
next.splice(idx + 1, 0, item);
await persistPublicModelsOrder(next);
await persistProviderModelsOrder(next);
});
headerActions.appendChild(downBtn);
@@ -705,7 +736,7 @@
await api(`/api/admin/models/${m.id}`, { method: 'DELETE' });
await loadConfigured();
} catch (err) {
setStatus(err.message, true);
setProviderModelStatus(err.message, true);
}
delBtn.disabled = false;
});
@@ -741,8 +772,8 @@
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
try {
await persistPublicModelChanges(m.id, { icon: sel.value });
} catch (err) { setStatus(err.message, true); }
await persistProviderModelChanges(m.id, { icon: sel.value });
} catch (err) { setProviderModelStatus(err.message, true); }
saveBtn.disabled = false;
});
editor.appendChild(saveBtn);
@@ -769,7 +800,7 @@
mediaCheckbox.addEventListener('change', async () => {
mediaCheckbox.disabled = true;
try {
await persistPublicModelChanges(m.id, { supportsMedia: mediaCheckbox.checked });
await persistProviderModelChanges(m.id, { supportsMedia: mediaCheckbox.checked });
} catch (err) { setStatus(err.message, true); }
mediaCheckbox.disabled = false;
});
@@ -834,11 +865,294 @@
});
}
// Render Public Models (completely separate from OpenCode)
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-facing models configured yet.';
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';
// Order number badge
const orderBadge = document.createElement('span');
orderBadge.className = 'pill';
orderBadge.textContent = `#${idx + 1}`;
orderBadge.style.background = idx === 0 ? 'var(--shopify-green)' : 'var(--primary)';
orderBadge.style.fontWeight = '700';
info.appendChild(orderBadge);
if (m.icon) {
const img = document.createElement('img');
img.src = m.icon;
img.alt = '';
info.appendChild(img);
}
const label = document.createElement('span');
label.textContent = m.label || m.name;
info.appendChild(label);
const namePill = document.createElement('span');
namePill.className = 'pill';
namePill.textContent = m.name;
info.appendChild(namePill);
const tierMeta = document.createElement('span');
tierMeta.className = 'pill';
const tierName = (m.tier || 'free').toUpperCase();
const multiplier = m.tier === 'pro' ? 3 : (m.tier === 'plus' ? 2 : 1);
tierMeta.textContent = `${tierName} (${multiplier}x)`;
info.appendChild(tierMeta);
if (m.supportsMedia) {
const mediaBadge = document.createElement('span');
mediaBadge.className = 'pill';
mediaBadge.style.background = 'var(--shopify-green)';
mediaBadge.textContent = 'Media';
info.appendChild(mediaBadge);
}
header.appendChild(info);
const headerActions = document.createElement('div');
headerActions.className = 'provider-row-actions';
// Up button
const upBtn = document.createElement('button');
upBtn.className = 'ghost';
upBtn.textContent = '↑';
upBtn.title = 'Move up';
upBtn.disabled = idx === 0;
upBtn.addEventListener('click', async () => {
if (idx === 0) return;
const next = [...state.publicModels];
const [item] = next.splice(idx, 1);
next.splice(idx - 1, 0, item);
await persistPublicModelsOrder(next);
});
headerActions.appendChild(upBtn);
// Down button
const downBtn = document.createElement('button');
downBtn.className = 'ghost';
downBtn.textContent = '↓';
downBtn.title = 'Move down';
downBtn.disabled = idx === state.publicModels.length - 1;
downBtn.addEventListener('click', async () => {
if (idx === state.publicModels.length - 1) return;
const next = [...state.publicModels];
const [item] = next.splice(idx, 1);
next.splice(idx + 1, 0, item);
await persistPublicModelsOrder(next);
});
headerActions.appendChild(downBtn);
const delBtn = document.createElement('button');
delBtn.className = 'ghost';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => {
delBtn.disabled = true;
try {
await api(`/api/admin/models/${m.id}`, { method: 'DELETE' });
await loadConfigured();
} catch (err) {
setPublicModelStatus(err.message, true);
}
delBtn.disabled = false;
});
headerActions.appendChild(delBtn);
// Inline icon editor button
const editIconBtn = document.createElement('button');
editIconBtn.className = 'ghost';
editIconBtn.textContent = 'Edit icon';
editIconBtn.addEventListener('click', () => {
let editor = header.querySelector('.icon-editor');
if (editor) return editor.remove();
editor = document.createElement('div');
editor.className = 'icon-editor';
const sel = document.createElement('select');
const none = document.createElement('option');
none.value = '';
none.textContent = 'No icon';
sel.appendChild(none);
(state.icons || []).forEach((iconPath) => {
const o = document.createElement('option');
o.value = iconPath;
o.textContent = iconPath.replace('/assets/', '');
sel.appendChild(o);
});
sel.value = m.icon || '';
editor.appendChild(sel);
const saveBtn = document.createElement('button');
saveBtn.className = 'primary';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
try {
await persistPublicModelChanges(m.id, { icon: sel.value });
} catch (err) { setPublicModelStatus(err.message, true); }
saveBtn.disabled = false;
});
editor.appendChild(saveBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'ghost';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => editor.remove());
editor.appendChild(cancelBtn);
headerActions.appendChild(editor);
});
headerActions.appendChild(editIconBtn);
// Supports media checkbox
const mediaToggle = document.createElement('label');
mediaToggle.style.display = 'flex';
mediaToggle.style.alignItems = 'center';
mediaToggle.style.gap = '6px';
mediaToggle.style.marginLeft = '8px';
const mediaCheckbox = document.createElement('input');
mediaCheckbox.type = 'checkbox';
mediaCheckbox.checked = m.supportsMedia ?? false;
mediaCheckbox.addEventListener('change', async () => {
mediaCheckbox.disabled = true;
try {
await persistPublicModelChanges(m.id, { supportsMedia: mediaCheckbox.checked });
} catch (err) { setPublicModelStatus(err.message, true); }
mediaCheckbox.disabled = false;
});
mediaToggle.appendChild(mediaCheckbox);
const mediaLabel = document.createElement('span');
mediaLabel.textContent = 'Supports image uploads';
mediaLabel.style.fontSize = '12px';
mediaLabel.style.color = 'var(--muted)';
mediaToggle.appendChild(mediaLabel);
headerActions.appendChild(mediaToggle);
// Tier editor button
const editTierBtn = document.createElement('button');
editTierBtn.className = 'ghost';
editTierBtn.textContent = 'Edit tier/multiplier';
editTierBtn.addEventListener('click', () => {
let editor = header.querySelector('.tier-editor');
if (editor) return editor.remove();
editor = document.createElement('div');
editor.className = 'tier-editor';
const sel = document.createElement('select');
const options = [
{ value: 'free', label: 'Free (1x)' },
{ value: 'plus', label: 'Plus (2x)' },
{ value: 'pro', label: 'Pro (3x)' }
];
options.forEach((opt) => {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.label;
sel.appendChild(o);
});
sel.value = m.tier || 'free';
editor.appendChild(sel);
const saveBtn = document.createElement('button');
saveBtn.className = 'primary';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
try {
await persistPublicModelChanges(m.id, { tier: sel.value });
} catch (err) { setPublicModelStatus(err.message, true); }
saveBtn.disabled = false;
});
editor.appendChild(saveBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'ghost';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', () => editor.remove());
editor.appendChild(cancelBtn);
headerActions.appendChild(editor);
});
headerActions.appendChild(editTierBtn);
header.appendChild(headerActions);
row.appendChild(header);
el.publicModelsList.appendChild(row);
});
}
async function persistProviderModelChanges(modelId, changes) {
setProviderModelStatus('Saving...');
const model = state.providerModels.find((m) => m.id === modelId);
if (!model) {
setProviderModelStatus('Model not found', true);
return;
}
const payload = {
type: 'providerModel',
id: modelId,
name: model.name,
label: model.label || model.name,
icon: changes.icon !== undefined ? changes.icon : model.icon,
tier: changes.tier !== undefined ? changes.tier : model.tier,
supportsMedia: changes.supportsMedia !== undefined ? changes.supportsMedia : model.supportsMedia,
};
try {
const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
const idx = state.providerModels.findIndex((m) => m.id === modelId);
if (idx >= 0) state.providerModels[idx] = { ...state.providerModels[idx], ...data.providerModel };
renderProviderModels();
setProviderModelStatus('Saved');
setTimeout(() => setProviderModelStatus(''), 1500);
} catch (err) {
setProviderModelStatus(err.message, true);
}
}
async function persistProviderModelsOrder(orderedModels) {
setProviderModelStatus('Saving order...');
try {
const res = await api('/api/admin/models/reorder', {
method: 'POST',
body: JSON.stringify({ type: 'providerModels', models: orderedModels }),
});
state.providerModels = res.providerModels || orderedModels;
renderProviderModels();
setProviderModelStatus('Order saved');
setTimeout(() => setProviderModelStatus(''), 1500);
} catch (err) {
setProviderModelStatus(err.message, true);
}
}
async function persistPublicModelChanges(modelId, changes) {
setStatus('Saving...');
setPublicModelStatus('Saving...');
const model = state.publicModels.find((m) => m.id === modelId);
if (!model) {
setStatus('Model not found', true);
setPublicModelStatus('Model not found', true);
return;
}
@@ -865,20 +1179,20 @@
}
async function persistPublicModelsOrder(orderedModels) {
setStatus('Saving order...');
setPublicModelStatus('Saving order...');
try {
// Use the reorder endpoint to save the new order
const res = await api('/api/admin/models/reorder', {
method: 'POST',
body: JSON.stringify({ models: orderedModels }),
body: JSON.stringify({ type: 'publicModels', models: orderedModels }),
});
// Update local state with new order from server
state.publicModels = res.publicModels || orderedModels;
renderConfigured();
setStatus('Order saved');
setTimeout(() => setStatus(''), 1500);
renderPublicModels();
setPublicModelStatus('Order saved');
setTimeout(() => setPublicModelStatus(''), 1500);
} catch (err) {
setStatus(err.message, true);
setPublicModelStatus(err.message, true);
}
}
@@ -1329,11 +1643,13 @@
async function loadConfigured() {
const data = await api('/api/admin/models');
// Handle new unified structure
// Handle new structure - separate provider models and public models
state.providerModels = data.providerModels || [];
state.publicModels = data.publicModels || [];
state.providerChain = data.providerChain || [];
state.configured = data.models || []; // Legacy support
renderConfigured();
renderProviderModels();
renderPublicModels();
renderProviderChain();
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
? state.planSettings.freePlanModel
@@ -2281,7 +2597,62 @@
}
}
// New public model form handler
// Provider model form handler (with OpenCode integration)
if (el.providerModelForm) {
el.providerModelForm.addEventListener('submit', async (e) => {
e.preventDefault();
const model = el.availableModels.value;
const label = el.providerModelLabel.value.trim();
const icon = el.providerModelIcon ? el.providerModelIcon.value : '';
const tier = el.providerModelTier ? el.providerModelTier.value : 'free';
const supportsMedia = el.providerModelMedia ? el.providerModelMedia.checked : false;
if (!model) {
setProviderModelStatus('Please select a model from OpenCode.', true);
return;
}
if (!label) {
setProviderModelStatus('Display name is required.', true);
return;
}
setProviderModelStatus('Saving...');
try {
await api('/api/admin/models', {
method: 'POST',
body: JSON.stringify({
type: 'providerModel',
name: model,
label,
icon,
tier,
supportsMedia
}),
});
setProviderModelStatus('Saved');
el.providerModelLabel.value = '';
await loadConfigured();
} catch (err) {
setProviderModelStatus(err.message, true);
}
});
}
// Reload available models button
if (el.reloadAvailable) {
el.reloadAvailable.addEventListener('click', async () => {
setProviderModelStatus('Loading...');
try {
await loadAvailable();
setProviderModelStatus('Models loaded');
setTimeout(() => setProviderModelStatus(''), 1500);
} catch (err) {
setProviderModelStatus(err.message, true);
}
});
}
// Public model form handler (completely separate from OpenCode)
if (el.publicModelForm) {
el.publicModelForm.addEventListener('submit', async (e) => {
e.preventDefault();