- Removed opencodeChain variable entirely - Removed chain form/list from admin UI - Fallback now uses the order of models in the OpenCode models list - Updated buildOpencodeAttemptChain to iterate through opencodeModels - Removed chain-related API endpoints - Simplified to just two lists: opencodeModels and publicModels
403 lines
14 KiB
JavaScript
403 lines
14 KiB
JavaScript
(() => {
|
|
const pageType = document?.body?.dataset?.page || 'build';
|
|
console.log('Admin JS loaded, pageType:', pageType);
|
|
|
|
// Clean state structure - just two things
|
|
const state = {
|
|
opencodeModels: [], // Models from OpenCode (order determines fallback)
|
|
publicModels: [], // User-facing models (completely separate)
|
|
icons: [],
|
|
availableOpencodeModels: [], // Loaded from OpenCode
|
|
};
|
|
|
|
// Element references
|
|
const el = {
|
|
// OpenCode Models
|
|
opencodeModelForm: document.getElementById('opencode-model-form'),
|
|
opencodeModelSelect: document.getElementById('opencode-model-select'),
|
|
opencodeModelLabel: document.getElementById('opencode-model-label'),
|
|
opencodeModelTier: document.getElementById('opencode-model-tier'),
|
|
opencodeModelIcon: document.getElementById('opencode-model-icon'),
|
|
opencodeModelMedia: document.getElementById('opencode-model-media'),
|
|
opencodeModelStatus: document.getElementById('opencode-model-status'),
|
|
reloadOpencodeModels: document.getElementById('reload-opencode-models'),
|
|
opencodeModelsList: document.getElementById('opencode-models-list'),
|
|
opencodeModelsCount: document.getElementById('opencode-models-count'),
|
|
|
|
// Public Models
|
|
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'),
|
|
|
|
// Other
|
|
iconList: document.getElementById('icon-list'),
|
|
adminRefresh: document.getElementById('admin-refresh'),
|
|
adminLogout: document.getElementById('admin-logout'),
|
|
cancelAllMessages: document.getElementById('cancel-all-messages'),
|
|
cancelMessagesStatus: document.getElementById('cancel-messages-status'),
|
|
};
|
|
|
|
// Helper functions
|
|
function setStatus(el, msg, isError = false) {
|
|
if (!el) return;
|
|
el.textContent = msg || '';
|
|
el.style.color = isError ? 'var(--danger)' : 'inherit';
|
|
}
|
|
|
|
async function api(url, options = {}) {
|
|
const res = await fetch(url, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...options,
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Request failed');
|
|
return data;
|
|
}
|
|
|
|
// Load available models from OpenCode
|
|
async function loadAvailableOpencodeModels() {
|
|
try {
|
|
const data = await api('/api/admin/available-models');
|
|
state.availableOpencodeModels = data.models || [];
|
|
renderOpencodeModelSelect();
|
|
} catch (err) {
|
|
console.error('Failed to load OpenCode models:', err);
|
|
}
|
|
}
|
|
|
|
// Render OpenCode model dropdown
|
|
function renderOpencodeModelSelect() {
|
|
if (!el.opencodeModelSelect) return;
|
|
el.opencodeModelSelect.innerHTML = '';
|
|
|
|
if (!state.availableOpencodeModels.length) {
|
|
const opt = document.createElement('option');
|
|
opt.value = '';
|
|
opt.textContent = 'No models available';
|
|
el.opencodeModelSelect.appendChild(opt);
|
|
return;
|
|
}
|
|
|
|
state.availableOpencodeModels.forEach((m) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = m.name || m.id || m;
|
|
opt.textContent = m.label || m.name || m.id || m;
|
|
el.opencodeModelSelect.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
// Render icons in selects
|
|
function renderIconOptions(selectEl) {
|
|
if (!selectEl) return;
|
|
const currentValue = selectEl.value;
|
|
selectEl.innerHTML = '<option value="">No icon</option>';
|
|
state.icons.forEach((iconPath) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = iconPath;
|
|
opt.textContent = iconPath.replace('/assets/', '');
|
|
selectEl.appendChild(opt);
|
|
});
|
|
selectEl.value = currentValue;
|
|
}
|
|
|
|
// Load icons
|
|
async function loadIcons() {
|
|
try {
|
|
const data = await api('/api/admin/icons');
|
|
state.icons = data.icons || [];
|
|
renderIconOptions(el.opencodeModelIcon);
|
|
renderIconOptions(el.publicModelIcon);
|
|
renderIconLibrary();
|
|
} catch (err) {
|
|
console.error('Failed to load icons:', err);
|
|
}
|
|
}
|
|
|
|
// Render icon library
|
|
function renderIconLibrary() {
|
|
if (!el.iconList) return;
|
|
el.iconList.innerHTML = '';
|
|
|
|
if (!state.icons.length) {
|
|
el.iconList.innerHTML = '<div class="muted">No icons uploaded yet. Add files to /chat/public/assets</div>';
|
|
return;
|
|
}
|
|
|
|
state.icons.forEach((iconPath) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'admin-row';
|
|
row.innerHTML = `
|
|
<div class="model-chip">
|
|
<img src="${iconPath}" alt="" style="width: 24px; height: 24px; object-fit: contain;">
|
|
<span>${iconPath.replace('/assets/', '')}</span>
|
|
</div>
|
|
`;
|
|
el.iconList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Load all model data
|
|
async function loadModels() {
|
|
try {
|
|
const data = await api('/api/admin/models');
|
|
state.opencodeModels = data.opencodeModels || [];
|
|
state.publicModels = data.publicModels || [];
|
|
renderOpencodeModels();
|
|
renderPublicModels();
|
|
} catch (err) {
|
|
console.error('Failed to load models:', err);
|
|
}
|
|
}
|
|
|
|
// Render OpenCode Models list
|
|
function renderOpencodeModels() {
|
|
if (!el.opencodeModelsList) return;
|
|
el.opencodeModelsList.innerHTML = '';
|
|
|
|
if (el.opencodeModelsCount) {
|
|
el.opencodeModelsCount.textContent = state.opencodeModels.length.toString();
|
|
}
|
|
|
|
if (!state.opencodeModels.length) {
|
|
el.opencodeModelsList.innerHTML = '<div class="muted">No OpenCode models added yet.</div>';
|
|
return;
|
|
}
|
|
|
|
state.opencodeModels.forEach((m, idx) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'provider-row slim';
|
|
row.innerHTML = `
|
|
<div class="provider-row-header">
|
|
<div class="model-chip">
|
|
<span class="pill" style="background: ${idx === 0 ? 'var(--shopify-green)' : 'var(--primary)'}; font-weight: 700;">#${idx + 1}</span>
|
|
${m.icon ? `<img src="${m.icon}" alt="" style="width: 20px; height: 20px;">` : ''}
|
|
<span>${m.label || m.name}</span>
|
|
<span class="pill">${m.name}</span>
|
|
<span class="pill">${(m.tier || 'free').toUpperCase()} (${m.multiplier || 1}x)</span>
|
|
${m.supportsMedia ? '<span class="pill" style="background: var(--shopify-green);">Media</span>' : ''}
|
|
</div>
|
|
<div class="provider-row-actions">
|
|
<button class="ghost move-up" ${idx === 0 ? 'disabled' : ''}>↑</button>
|
|
<button class="ghost move-down" ${idx === state.opencodeModels.length - 1 ? 'disabled' : ''}>↓</button>
|
|
<button class="ghost delete-btn">Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add event listeners
|
|
row.querySelector('.move-up')?.addEventListener('click', () => moveOpencodeModel(idx, -1));
|
|
row.querySelector('.move-down')?.addEventListener('click', () => moveOpencodeModel(idx, 1));
|
|
row.querySelector('.delete-btn')?.addEventListener('click', () => deleteModel(m.id, 'opencode'));
|
|
|
|
el.opencodeModelsList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Render Public Models
|
|
function renderPublicModels() {
|
|
if (!el.publicModelsList) return;
|
|
el.publicModelsList.innerHTML = '';
|
|
|
|
if (el.publicModelsCount) {
|
|
el.publicModelsCount.textContent = state.publicModels.length.toString();
|
|
}
|
|
|
|
if (!state.publicModels.length) {
|
|
el.publicModelsList.innerHTML = '<div class="muted">No public models added yet.</div>';
|
|
return;
|
|
}
|
|
|
|
state.publicModels.forEach((m, idx) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'provider-row slim';
|
|
row.innerHTML = `
|
|
<div class="provider-row-header">
|
|
<div class="model-chip">
|
|
<span class="pill" style="background: ${idx === 0 ? 'var(--shopify-green)' : 'var(--primary)'}; font-weight: 700;">#${idx + 1}</span>
|
|
${m.icon ? `<img src="${m.icon}" alt="" style="width: 20px; height: 20px;">` : ''}
|
|
<span>${m.label || m.name}</span>
|
|
<span class="pill">${m.name}</span>
|
|
<span class="pill">${(m.tier || 'free').toUpperCase()} (${m.multiplier || 1}x)</span>
|
|
${m.supportsMedia ? '<span class="pill" style="background: var(--shopify-green);">Media</span>' : ''}
|
|
</div>
|
|
<div class="provider-row-actions">
|
|
<button class="ghost move-up" ${idx === 0 ? 'disabled' : ''}>↑</button>
|
|
<button class="ghost move-down" ${idx === state.publicModels.length - 1 ? 'disabled' : ''}>↓</button>
|
|
<button class="ghost delete-btn">Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
row.querySelector('.move-up')?.addEventListener('click', () => movePublicModel(idx, -1));
|
|
row.querySelector('.move-down')?.addEventListener('click', () => movePublicModel(idx, 1));
|
|
row.querySelector('.delete-btn')?.addEventListener('click', () => deleteModel(m.id, 'public'));
|
|
|
|
el.publicModelsList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Move OpenCode Model
|
|
async function moveOpencodeModel(fromIdx, direction) {
|
|
const toIdx = fromIdx + direction;
|
|
if (toIdx < 0 || toIdx >= state.opencodeModels.length) return;
|
|
|
|
const newOrder = [...state.opencodeModels];
|
|
const [item] = newOrder.splice(fromIdx, 1);
|
|
newOrder.splice(toIdx, 0, item);
|
|
|
|
try {
|
|
await api('/api/admin/models/reorder', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ type: 'opencode', models: newOrder }),
|
|
});
|
|
state.opencodeModels = newOrder;
|
|
renderOpencodeModels();
|
|
} catch (err) {
|
|
console.error('Failed to reorder:', err);
|
|
}
|
|
}
|
|
|
|
// Move Public Model
|
|
async function movePublicModel(fromIdx, direction) {
|
|
const toIdx = fromIdx + direction;
|
|
if (toIdx < 0 || toIdx >= state.publicModels.length) return;
|
|
|
|
const newOrder = [...state.publicModels];
|
|
const [item] = newOrder.splice(fromIdx, 1);
|
|
newOrder.splice(toIdx, 0, item);
|
|
|
|
try {
|
|
await api('/api/admin/models/reorder', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ type: 'public', models: newOrder }),
|
|
});
|
|
state.publicModels = newOrder;
|
|
renderPublicModels();
|
|
} catch (err) {
|
|
console.error('Failed to reorder:', err);
|
|
}
|
|
}
|
|
|
|
// Delete Model
|
|
async function deleteModel(id, type) {
|
|
try {
|
|
await api(`/api/admin/models/${id}?type=${type}`, { method: 'DELETE' });
|
|
await loadModels();
|
|
} catch (err) {
|
|
console.error('Failed to delete:', err);
|
|
}
|
|
}
|
|
|
|
// Form Handlers
|
|
if (el.opencodeModelForm) {
|
|
el.opencodeModelForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const model = el.opencodeModelSelect.value;
|
|
const label = el.opencodeModelLabel.value.trim();
|
|
|
|
if (!model || !label) {
|
|
setStatus(el.opencodeModelStatus, 'Model and display name are required', true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api('/api/admin/models', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
type: 'opencode',
|
|
name: model,
|
|
label,
|
|
tier: el.opencodeModelTier?.value || 'free',
|
|
icon: el.opencodeModelIcon?.value || '',
|
|
supportsMedia: el.opencodeModelMedia?.checked || false,
|
|
}),
|
|
});
|
|
setStatus(el.opencodeModelStatus, 'Added');
|
|
el.opencodeModelLabel.value = '';
|
|
await loadModels();
|
|
} catch (err) {
|
|
setStatus(el.opencodeModelStatus, err.message, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (el.reloadOpencodeModels) {
|
|
el.reloadOpencodeModels.addEventListener('click', async () => {
|
|
setStatus(el.opencodeModelStatus, 'Loading...');
|
|
await loadAvailableOpencodeModels();
|
|
setStatus(el.opencodeModelStatus, 'Loaded');
|
|
setTimeout(() => setStatus(el.opencodeModelStatus, ''), 1500);
|
|
});
|
|
}
|
|
|
|
if (el.publicModelForm) {
|
|
el.publicModelForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const name = el.publicModelName.value.trim();
|
|
const label = el.publicModelLabel.value.trim();
|
|
|
|
if (!name || !label) {
|
|
setStatus(el.publicModelStatus, 'Model ID and display name are required', true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api('/api/admin/models', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
type: 'public',
|
|
name,
|
|
label,
|
|
tier: el.publicModelTier?.value || 'free',
|
|
icon: el.publicModelIcon?.value || '',
|
|
supportsMedia: el.publicModelMedia?.checked || false,
|
|
}),
|
|
});
|
|
setStatus(el.publicModelStatus, 'Added');
|
|
el.publicModelName.value = '';
|
|
el.publicModelLabel.value = '';
|
|
await loadModels();
|
|
} catch (err) {
|
|
setStatus(el.publicModelStatus, err.message, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize
|
|
async function init() {
|
|
await loadIcons();
|
|
await loadAvailableOpencodeModels();
|
|
await loadModels();
|
|
}
|
|
|
|
if (el.adminRefresh) {
|
|
el.adminRefresh.addEventListener('click', init);
|
|
}
|
|
|
|
if (el.adminLogout) {
|
|
el.adminLogout.addEventListener('click', async () => {
|
|
await api('/api/admin/logout', { method: 'POST' });
|
|
window.location.href = '/admin/login';
|
|
});
|
|
}
|
|
|
|
if (el.cancelAllMessages) {
|
|
el.cancelAllMessages.addEventListener('click', async () => {
|
|
if (!confirm('Are you sure you want to cancel all running and queued messages?')) return;
|
|
try {
|
|
await api('/api/admin/cancel-all-messages', { method: 'POST' });
|
|
setStatus(el.cancelMessagesStatus, 'All messages cancelled');
|
|
} catch (err) {
|
|
setStatus(el.cancelMessagesStatus, err.message, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
init();
|
|
})(); |