Separate OpenCode and Public model management in admin panel
- Add OpenCode Models section with dropdown selection from available models - Add Public Models section with manual model ID input - Both sections have up/down ordering buttons for fallback chain priority - OpenCode models used for execution fallback when rate limits/errors occur - Public models displayed in builder dropdown for user selection - Remove unified provider chain in favor of two separate lists - Keep all existing functionality: Auto Model, Provider Limits, Icon Library, etc.
This commit is contained in:
@@ -68,17 +68,61 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-grid">
|
<div class="admin-grid">
|
||||||
<!-- Public Facing Models Section -->
|
<!-- OpenCode Models Section -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<header>
|
<header>
|
||||||
<h3>Add Public-Facing Model</h3>
|
<h3>Add OpenCode Model</h3>
|
||||||
|
<div class="pill">OpenCode</div>
|
||||||
|
</header>
|
||||||
|
<p style="margin-top:0; color: var(--muted);">These are the models available in OpenCode. They form the fallback chain for build execution. When rate limits are reached or errors occur, the system falls back to the next model in the list.</p>
|
||||||
|
<form id="opencode-model-form" class="admin-form">
|
||||||
|
<label>
|
||||||
|
Select model from OpenCode
|
||||||
|
<select id="opencode-model-select">
|
||||||
|
<option value="">-- Select a model --</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Display name shown to users
|
||||||
|
<input id="opencode-model-label" type="text" placeholder="Friendly label (e.g., Claude 3.5 Sonnet)" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Model tier (for plan limits)
|
||||||
|
<select id="opencode-model-tier">
|
||||||
|
<option value="free">Free (1x multiplier)</option>
|
||||||
|
<option value="plus">Plus (2x multiplier)</option>
|
||||||
|
<option value="pro">Pro (3x multiplier)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Icon (files in /assets)
|
||||||
|
<select id="opencode-model-icon">
|
||||||
|
<option value="">No icon</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||||
|
<input id="opencode-model-media" type="checkbox" style="width: auto;" />
|
||||||
|
<span>Supports image uploads</span>
|
||||||
|
</label>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button type="submit" class="primary">Add OpenCode Model</button>
|
||||||
|
<button type="button" id="reload-opencode-models" class="ghost">Reload Models</button>
|
||||||
|
</div>
|
||||||
|
<div class="status-line" id="opencode-model-status"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Public Models Section -->
|
||||||
|
<div class="admin-card">
|
||||||
|
<header>
|
||||||
|
<h3>Add Public Model</h3>
|
||||||
<div class="pill">Public</div>
|
<div class="pill">Public</div>
|
||||||
</header>
|
</header>
|
||||||
<p style="margin-top:0; color: var(--muted);">These models are displayed to users in the builder dropdown. They all share the same unified provider fallback chain.</p>
|
<p style="margin-top:0; color: var(--muted);">These models are displayed to users in the builder dropdown for selection. This is separate from the OpenCode fallback chain.</p>
|
||||||
<form id="public-model-form" class="admin-form">
|
<form id="public-model-form" class="admin-form">
|
||||||
<label>
|
<label>
|
||||||
Model ID (e.g., claude-3-5-sonnet, gpt-4o)
|
Model ID (e.g., claude-3-5-sonnet, gpt-4o)
|
||||||
<input id="public-model-name" type="text" placeholder="Model ID from OpenCode" required />
|
<input id="public-model-name" type="text" placeholder="Enter model ID manually" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Display name shown to users
|
Display name shown to users
|
||||||
@@ -108,50 +152,26 @@
|
|||||||
<div class="status-line" id="public-model-status"></div>
|
<div class="status-line" id="public-model-status"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Provider Model Chain Section -->
|
|
||||||
<div class="admin-card">
|
|
||||||
<header>
|
|
||||||
<h3>Provider Model Chain</h3>
|
|
||||||
<div class="pill">Backend</div>
|
|
||||||
</header>
|
|
||||||
<p style="margin-top:0; color: var(--muted);">This is the unified fallback chain used for ALL models. When rate limits are reached or errors occur, the system automatically falls back to the next provider in this chain.</p>
|
|
||||||
<form id="provider-chain-form" class="admin-form">
|
|
||||||
<label>
|
|
||||||
Provider
|
|
||||||
<select id="chain-provider">
|
|
||||||
<option value="openrouter">OpenRouter</option>
|
|
||||||
<option value="mistral">Mistral</option>
|
|
||||||
<option value="google">Google</option>
|
|
||||||
<option value="groq">Groq</option>
|
|
||||||
<option value="nvidia">NVIDIA</option>
|
|
||||||
<option value="chutes">Chutes</option>
|
|
||||||
<option value="cerebras">Cerebras</option>
|
|
||||||
<option value="ollama">Ollama</option>
|
|
||||||
<option value="opencode">OpenCode</option>
|
|
||||||
<option value="cohere">Cohere</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Model Name
|
|
||||||
<input id="chain-model" type="text" placeholder="e.g., anthropic/claude-3.5-sonnet" required />
|
|
||||||
</label>
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button type="submit" class="primary">Add to Chain</button>
|
|
||||||
</div>
|
|
||||||
<div class="status-line" id="provider-chain-status"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Provider Chain List with Ordering -->
|
<!-- OpenCode Models List with Ordering -->
|
||||||
<div class="admin-card" style="margin-top: 16px;">
|
<div class="admin-card" style="margin-top: 16px;">
|
||||||
<header>
|
<header>
|
||||||
<h3>Unified Provider Chain Order</h3>
|
<h3>OpenCode Models (Fallback Chain)</h3>
|
||||||
<div class="pill" id="provider-chain-count">0</div>
|
<div class="pill" id="opencode-models-count">0</div>
|
||||||
</header>
|
</header>
|
||||||
<p class="muted" style="margin-top:0;">Arrange the order of providers below. The system will try each provider in order when rate limits are reached or errors occur. The first provider is the primary.</p>
|
<p class="muted" style="margin-top:0;">Arrange the order of OpenCode models below. When rate limits are reached or errors occur, the system falls back to the next model in this chain. The first model is the primary.</p>
|
||||||
<div id="provider-chain-list" class="admin-list"></div>
|
<div id="opencode-models-list" class="admin-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Public Models List with Ordering -->
|
||||||
|
<div class="admin-card" style="margin-top: 16px;">
|
||||||
|
<header>
|
||||||
|
<h3>Public Models</h3>
|
||||||
|
<div class="pill" id="public-models-count">0</div>
|
||||||
|
</header>
|
||||||
|
<p class="muted" style="margin-top:0;">These models are displayed to users in the builder dropdown. Arrange the order to set their display priority.</p>
|
||||||
|
<div id="public-models-list" class="admin-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-grid" style="margin-top: 16px;">
|
<div class="admin-grid" style="margin-top: 16px;">
|
||||||
@@ -329,16 +349,6 @@
|
|||||||
<div id="provider-usage" class="admin-list"></div>
|
<div id="provider-usage" class="admin-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Public Models List -->
|
|
||||||
<div class="admin-card" style="margin-top: 16px;">
|
|
||||||
<header>
|
|
||||||
<h3>Public-Facing Models</h3>
|
|
||||||
<div class="pill" id="configured-count">0</div>
|
|
||||||
</header>
|
|
||||||
<p class="muted" style="margin-top:0;">These models are displayed to users in the builder. All models use the unified provider chain above for fallback.</p>
|
|
||||||
<div id="configured-list" class="admin-list"></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="/admin.js"></script>
|
<script src="/admin.js"></script>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
const state = {
|
const state = {
|
||||||
available: [],
|
available: [],
|
||||||
configured: [],
|
configured: [],
|
||||||
opencodeModels: [], // Models from OpenCode (order determines fallback)
|
opencodeModels: [], // Models from OpenCode (order determines fallback chain for execution)
|
||||||
publicModels: [], // Public-facing models (completely separate)
|
publicModels: [], // Public-facing models (displayed to users in builder dropdown)
|
||||||
icons: [],
|
icons: [],
|
||||||
accounts: [],
|
accounts: [],
|
||||||
affiliates: [],
|
affiliates: [],
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
// OpenCode Models (new structure)
|
// OpenCode Models (fallback chain)
|
||||||
opencodeModelForm: document.getElementById('opencode-model-form'),
|
opencodeModelForm: document.getElementById('opencode-model-form'),
|
||||||
opencodeModelSelect: document.getElementById('opencode-model-select'),
|
opencodeModelSelect: document.getElementById('opencode-model-select'),
|
||||||
opencodeModelLabel: document.getElementById('opencode-model-label'),
|
opencodeModelLabel: document.getElementById('opencode-model-label'),
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
reloadOpencodeModels: document.getElementById('reload-opencode-models'),
|
reloadOpencodeModels: document.getElementById('reload-opencode-models'),
|
||||||
opencodeModelsList: document.getElementById('opencode-models-list'),
|
opencodeModelsList: document.getElementById('opencode-models-list'),
|
||||||
opencodeModelsCount: document.getElementById('opencode-models-count'),
|
opencodeModelsCount: document.getElementById('opencode-models-count'),
|
||||||
// Public Models (new structure)
|
// Public Models (user-facing selection)
|
||||||
publicModelForm: document.getElementById('public-model-form'),
|
publicModelForm: document.getElementById('public-model-form'),
|
||||||
publicModelName: document.getElementById('public-model-name'),
|
publicModelName: document.getElementById('public-model-name'),
|
||||||
publicModelLabel: document.getElementById('public-model-label'),
|
publicModelLabel: document.getElementById('public-model-label'),
|
||||||
@@ -43,6 +43,12 @@
|
|||||||
publicModelStatus: document.getElementById('public-model-status'),
|
publicModelStatus: document.getElementById('public-model-status'),
|
||||||
publicModelsList: document.getElementById('public-models-list'),
|
publicModelsList: document.getElementById('public-models-list'),
|
||||||
publicModelsCount: document.getElementById('public-models-count'),
|
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)
|
// Legacy elements (keep for compatibility)
|
||||||
availableModels: document.getElementById('available-models'),
|
availableModels: document.getElementById('available-models'),
|
||||||
displayLabel: document.getElementById('display-label'),
|
displayLabel: document.getElementById('display-label'),
|
||||||
@@ -211,6 +217,12 @@
|
|||||||
el.publicModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
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) {
|
function setProviderChainStatus(msg, isError = false) {
|
||||||
if (!el.providerChainStatus) return;
|
if (!el.providerChainStatus) return;
|
||||||
el.providerChainStatus.textContent = msg || '';
|
el.providerChainStatus.textContent = msg || '';
|
||||||
@@ -553,7 +565,22 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate new public model icon select
|
// 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) {
|
if (el.publicModelIcon) {
|
||||||
el.publicModelIcon.innerHTML = '';
|
el.publicModelIcon.innerHTML = '';
|
||||||
const none = document.createElement('option');
|
const none = document.createElement('option');
|
||||||
@@ -595,24 +622,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simplified renderConfigured for new publicModels structure
|
// Render OpenCode models list with up/down ordering
|
||||||
function renderConfigured() {
|
function renderOpencodeModels() {
|
||||||
if (!el.configuredList) return;
|
if (!el.opencodeModelsList) return;
|
||||||
el.configuredList.innerHTML = '';
|
el.opencodeModelsList.innerHTML = '';
|
||||||
|
if (el.opencodeModelsCount) el.opencodeModelsCount.textContent = state.opencodeModels.length.toString();
|
||||||
|
|
||||||
// Use publicModels if available, otherwise fall back to configured
|
if (!state.opencodeModels.length) {
|
||||||
const modelsToRender = state.publicModels.length > 0 ? state.publicModels : state.configured;
|
|
||||||
|
|
||||||
if (el.configuredCount) el.configuredCount.textContent = modelsToRender.length.toString();
|
|
||||||
if (!modelsToRender.length) {
|
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'muted';
|
empty.className = 'muted';
|
||||||
empty.textContent = 'No public-facing models configured yet.';
|
empty.textContent = 'No OpenCode models configured. Add models to enable fallback chain for build execution.';
|
||||||
el.configuredList.appendChild(empty);
|
el.opencodeModelsList.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
modelsToRender.forEach((m) => {
|
state.opencodeModels.forEach((m, idx) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'provider-row slim';
|
row.className = 'provider-row slim';
|
||||||
|
|
||||||
@@ -622,6 +646,13 @@
|
|||||||
const info = document.createElement('div');
|
const info = document.createElement('div');
|
||||||
info.className = 'model-chip';
|
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) {
|
if (m.icon) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = m.icon;
|
img.src = m.icon;
|
||||||
@@ -653,25 +684,232 @@
|
|||||||
info.appendChild(mediaBadge);
|
info.appendChild(mediaBadge);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show chain info badge
|
|
||||||
const chainBadge = document.createElement('span');
|
|
||||||
chainBadge.className = 'pill';
|
|
||||||
chainBadge.style.background = 'var(--primary)';
|
|
||||||
chainBadge.textContent = `Uses unified chain (${state.providerChain.length} providers)`;
|
|
||||||
info.appendChild(chainBadge);
|
|
||||||
|
|
||||||
header.appendChild(info);
|
header.appendChild(info);
|
||||||
|
|
||||||
const headerActions = document.createElement('div');
|
const headerActions = document.createElement('div');
|
||||||
headerActions.className = 'provider-row-actions';
|
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');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.className = 'ghost';
|
delBtn.className = 'ghost';
|
||||||
delBtn.textContent = 'Delete';
|
delBtn.textContent = 'Delete';
|
||||||
delBtn.addEventListener('click', async () => {
|
delBtn.addEventListener('click', async () => {
|
||||||
delBtn.disabled = true;
|
delBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await api(`/api/admin/models/${m.id}`, { method: 'DELETE' });
|
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();
|
await loadConfigured();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(err.message, true);
|
setStatus(err.message, true);
|
||||||
@@ -744,7 +982,7 @@
|
|||||||
});
|
});
|
||||||
mediaToggle.appendChild(mediaCheckbox);
|
mediaToggle.appendChild(mediaCheckbox);
|
||||||
const mediaLabel = document.createElement('span');
|
const mediaLabel = document.createElement('span');
|
||||||
mediaLabel.textContent = 'Supports image uploads';
|
mediaLabel.textContent = 'Media';
|
||||||
mediaLabel.style.fontSize = '12px';
|
mediaLabel.style.fontSize = '12px';
|
||||||
mediaLabel.style.color = 'var(--muted)';
|
mediaLabel.style.color = 'var(--muted)';
|
||||||
mediaToggle.appendChild(mediaLabel);
|
mediaToggle.appendChild(mediaLabel);
|
||||||
@@ -753,7 +991,7 @@
|
|||||||
// Tier editor button
|
// Tier editor button
|
||||||
const editTierBtn = document.createElement('button');
|
const editTierBtn = document.createElement('button');
|
||||||
editTierBtn.className = 'ghost';
|
editTierBtn.className = 'ghost';
|
||||||
editTierBtn.textContent = 'Edit tier/multiplier';
|
editTierBtn.textContent = 'Edit tier';
|
||||||
editTierBtn.addEventListener('click', () => {
|
editTierBtn.addEventListener('click', () => {
|
||||||
let editor = header.querySelector('.tier-editor');
|
let editor = header.querySelector('.tier-editor');
|
||||||
if (editor) return editor.remove();
|
if (editor) return editor.remove();
|
||||||
@@ -799,10 +1037,74 @@
|
|||||||
|
|
||||||
header.appendChild(headerActions);
|
header.appendChild(headerActions);
|
||||||
row.appendChild(header);
|
row.appendChild(header);
|
||||||
el.configuredList.appendChild(row);
|
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) {
|
async function persistPublicModelChanges(modelId, changes) {
|
||||||
setStatus('Saving...');
|
setStatus('Saving...');
|
||||||
const model = state.publicModels.find((m) => m.id === modelId);
|
const model = state.publicModels.find((m) => m.id === modelId);
|
||||||
@@ -1281,11 +1583,12 @@
|
|||||||
async function loadConfigured() {
|
async function loadConfigured() {
|
||||||
const data = await api('/api/admin/models');
|
const data = await api('/api/admin/models');
|
||||||
// Handle new unified structure
|
// Handle new unified structure
|
||||||
|
state.opencodeModels = data.opencodeModels || [];
|
||||||
state.publicModels = data.publicModels || [];
|
state.publicModels = data.publicModels || [];
|
||||||
state.providerChain = data.providerChain || [];
|
|
||||||
state.configured = data.models || []; // Legacy support
|
state.configured = data.models || []; // Legacy support
|
||||||
renderConfigured();
|
renderOpencodeModels();
|
||||||
renderProviderChain();
|
renderPublicModels();
|
||||||
|
populateOpencodeModelSelect();
|
||||||
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
|
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
|
||||||
? state.planSettings.freePlanModel
|
? state.planSettings.freePlanModel
|
||||||
: (el.autoModelSelect ? el.autoModelSelect.value : '');
|
: (el.autoModelSelect ? el.autoModelSelect.value : '');
|
||||||
@@ -1295,6 +1598,37 @@
|
|||||||
if (el.limitProvider) renderLimitModelOptions(el.limitProvider.value || 'openrouter');
|
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() {
|
async function loadOpenRouterSettings() {
|
||||||
if (!el.orForm) return;
|
if (!el.orForm) return;
|
||||||
try {
|
try {
|
||||||
@@ -1531,7 +1865,8 @@
|
|||||||
return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free';
|
return ['free', 'plus', 'pro'].includes(normalized) ? normalized : 'free';
|
||||||
};
|
};
|
||||||
|
|
||||||
const configured = Array.isArray(state.configured) ? state.configured : [];
|
// 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();
|
const configuredByName = new Map();
|
||||||
configured.forEach((m) => {
|
configured.forEach((m) => {
|
||||||
const name = (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '';
|
const name = (m && (m.name || m.id)) ? String(m.name || m.id).trim() : '';
|
||||||
@@ -2232,7 +2567,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New public model form handler
|
// 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) {
|
if (el.publicModelForm) {
|
||||||
el.publicModelForm.addEventListener('submit', async (e) => {
|
el.publicModelForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -2256,7 +2644,7 @@
|
|||||||
await api('/api/admin/models', {
|
await api('/api/admin/models', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
type: 'publicModel',
|
type: 'public',
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
@@ -2274,37 +2662,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// New provider chain form handler
|
|
||||||
if (el.providerChainForm) {
|
|
||||||
el.providerChainForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const provider = el.chainProvider.value;
|
|
||||||
const model = el.chainModel.value.trim();
|
|
||||||
|
|
||||||
if (!model) {
|
|
||||||
setProviderChainStatus('Model name is required.', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProviderChainStatus('Adding to chain...');
|
|
||||||
try {
|
|
||||||
const newChain = [...state.providerChain, { provider, model }];
|
|
||||||
await api('/api/admin/models', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: 'providerChain',
|
|
||||||
chain: newChain
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
setProviderChainStatus('Added to chain');
|
|
||||||
el.chainModel.value = '';
|
|
||||||
await loadConfigured();
|
|
||||||
} catch (err) {
|
|
||||||
setProviderChainStatus(err.message, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (el.modelForm) {
|
if (el.modelForm) {
|
||||||
el.modelForm.addEventListener('submit', async (e) => {
|
el.modelForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -11320,8 +11320,9 @@ async function processMessage(sessionId, message) {
|
|||||||
|
|
||||||
function getConfiguredModels(cliParam = 'opencode') {
|
function getConfiguredModels(cliParam = 'opencode') {
|
||||||
const cli = normalizeCli(cliParam || 'opencode');
|
const cli = normalizeCli(cliParam || 'opencode');
|
||||||
// Return opencode models for backwards compatibility
|
// Return public models for user-facing dropdown selection
|
||||||
const mapped = opencodeModels.map((m, idx) => ({
|
// These are the models displayed to users in the builder
|
||||||
|
const mapped = publicModels.map((m, idx) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
label: m.label || m.name,
|
label: m.label || m.name,
|
||||||
@@ -11336,6 +11337,20 @@ function getConfiguredModels(cliParam = 'opencode') {
|
|||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOpencodeModelsForExecution() {
|
||||||
|
// Return opencode models for internal fallback chain during execution
|
||||||
|
// These models are used when rate limits are reached or errors occur
|
||||||
|
return opencodeModels.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
label: m.label || m.name,
|
||||||
|
icon: m.icon || '',
|
||||||
|
tier: m.tier || 'free',
|
||||||
|
multiplier: getTierMultiplier(m.tier || 'free'),
|
||||||
|
supportsMedia: m.supportsMedia ?? false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleModels(_req, res, cliParam = null) {
|
async function handleModels(_req, res, cliParam = null) {
|
||||||
try {
|
try {
|
||||||
const models = getConfiguredModels(cliParam || 'opencode');
|
const models = getConfiguredModels(cliParam || 'opencode');
|
||||||
|
|||||||
Reference in New Issue
Block a user