sharktide's picture
download
raw
53.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>InferencePort Lightning - Model Editor</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a0f;--bg2:#12121a;--bg3:#1a1a2e;--bg4:#22223a;--border:#2a2a44;--border2:#3a3a5e;--text:#e0e0f0;--text2:#8888aa;--accent:#6c5ce7;--accent2:#a29bfe;--green:#00b894;--red:#e17055;--orange:#fdcb6e;--blue:#74b9ff;--pink:#fd79a8;--radius:8px;--shadow:0 4px 24px rgba(0,0,0,.4)}
body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;overflow-x:hidden}
::selection{background:var(--accent);color:#fff}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-track{background:var(--bg2)}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
.header{background:var(--bg2);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;gap:16px;position:sticky;top:0;z-index:100}
.header h1{font-size:18px;font-weight:700;background:linear-gradient(135deg,var(--accent2),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.header .subtitle{color:var(--text2);font-size:12px}
.header .spacer{flex:1}
.header .status{font-size:12px;color:var(--text2);padding:4px 12px;background:var(--bg3);border-radius:20px;border:1px solid var(--border)}
.header .status.ok{border-color:var(--green);color:var(--green)}
.header .status.err{border-color:var(--red);color:var(--red)}
.tabs{display:flex;gap:2px;padding:8px 24px 0;background:var(--bg2);border-bottom:1px solid var(--border)}
.tab{padding:10px 20px;cursor:pointer;color:var(--text2);font-size:13px;font-weight:600;border-radius:var(--radius) var(--radius) 0 0;transition:all .2s;border:1px solid transparent;border-bottom:none;position:relative}
.tab:hover{color:var(--text);background:var(--bg3)}
.tab.active{color:var(--accent2);background:var(--bg);border-color:var(--border)}
.tab .badge{display:inline-block;background:var(--accent);color:#fff;font-size:10px;padding:1px 6px;border-radius:10px;margin-left:6px;font-weight:700}
.toolbar{padding:12px 24px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;background:var(--bg);border-bottom:1px solid var(--border)}
.toolbar input[type="text"]{background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 14px;border-radius:var(--radius);font-size:13px;width:280px;outline:none;transition:border-color .2s}
.toolbar input[type="text"]:focus{border-color:var(--accent)}
.toolbar .spacer{flex:1}
.btn{padding:8px 16px;border-radius:var(--radius);border:1px solid var(--border);background:var(--bg3);color:var(--text);font-size:13px;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:6px;font-weight:500}
.btn:hover{background:var(--bg4);border-color:var(--border2)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{background:#5b4bd5}
.btn.danger{border-color:var(--red);color:var(--red)}
.btn.danger:hover{background:var(--red);color:#fff}
.btn.success{border-color:var(--green);color:var(--green)}
.btn.success:hover{background:var(--green);color:#fff}
.btn.sm{padding:5px 10px;font-size:12px}
.btn:disabled{opacity:.4;cursor:not-allowed}
.main{padding:24px;max-width:1600px;margin:0 auto}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:12px;transition:border-color .2s}
.card:hover{border-color:var(--border2)}
.card-header{padding:14px 18px;display:flex;align-items:center;gap:12px;cursor:pointer;user-select:none}
.card-header .arrow{color:var(--text2);transition:transform .2s;font-size:10px;width:14px;text-align:center}
.card-header .arrow.open{transform:rotate(90deg)}
.card-header .model-name{font-weight:600;font-size:14px;flex:1}
.card-header .model-id{color:var(--text2);font-size:12px;font-family:monospace;background:var(--bg3);padding:2px 8px;border-radius:4px}
.card-header .provider-badge{font-size:11px;padding:3px 8px;border-radius:12px;font-weight:600;letter-spacing:.3px}
.card-header .pricing{color:var(--green);font-weight:600;font-size:13px}
.card-body{padding:0 18px 18px;display:none}
.card-body.open{display:block}
.field-group{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-top:12px}
.field{display:flex;flex-direction:column;gap:4px}
.field label{font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:600}
.field input,.field select,.field textarea{background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:var(--radius);font-size:13px;outline:none;transition:border-color .2s;font-family:inherit}
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--accent)}
.field textarea{min-height:60px;resize:vertical}
.field select{cursor:pointer}
.field .hint{font-size:11px;color:var(--text2)}
.array-field{display:flex;flex-direction:column;gap:6px}
.array-field .array-items{display:flex;flex-direction:column;gap:4px}
.array-field .array-item{display:flex;gap:6px;align-items:center}
.array-field .array-item input{flex:1;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:var(--radius);font-size:12px;font-family:monospace;outline:none}
.array-field .array-item input:focus{border-color:var(--accent)}
.array-field .array-add{display:flex;gap:6px;margin-top:4px}
.tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
.tag{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;cursor:pointer;transition:all .2s;border:1px solid var(--border)}
.tag.modality{background:rgba(108,92,231,.15);border-color:var(--accent);color:var(--accent2)}
.tag.feature{background:rgba(116,185,255,.15);border-color:var(--blue);color:var(--blue)}
.tag.allowed{background:rgba(0,184,148,.15);border-color:var(--green);color:var(--green)}
.tag.active{background:var(--accent);color:#fff}
.tag .remove{cursor:pointer;opacity:.6;font-size:14px;line-height:1}
.tag .remove:hover{opacity:1}
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:200;display:none;align-items:center;justify-content:center;backdrop-filter:blur(4px)}
.modal-overlay.open{display:flex}
.modal{background:var(--bg2);border:1px solid var(--border);border-radius:12px;width:90%;max-width:800px;max-height:85vh;overflow:auto;box-shadow:var(--shadow)}
.modal-header{padding:18px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;background:var(--bg2);z-index:1}
.modal-header h2{font-size:16px;font-weight:700}
.modal-close{background:none;border:none;color:var(--text2);font-size:24px;cursor:pointer;padding:4px 8px;border-radius:var(--radius)}
.modal-close:hover{color:var(--text);background:var(--bg3)}
.modal-body{padding:24px}
.modal-footer{padding:16px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px;position:sticky;bottom:0;background:var(--bg2)}
.empty-state{text-align:center;padding:60px 20px;color:var(--text2)}
.empty-state .icon{font-size:48px;margin-bottom:16px;opacity:.4}
.empty-state h3{color:var(--text);margin-bottom:8px}
.stats-bar{display:flex;gap:16px;margin-bottom:16px;flex-wrap:wrap}
.stat{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:12px 18px;min-width:140px}
.stat .label{font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px}
.stat .value{font-size:24px;font-weight:700;margin-top:4px}
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:var(--radius);font-size:13px;font-weight:500;z-index:300;transform:translateY(100px);opacity:0;transition:all .3s ease;pointer-events:none}
.toast.show{transform:translateY(0);opacity:1;pointer-events:auto}
.toast.success{background:var(--green);color:#fff}
.toast.error{background:var(--red);color:#fff}
.toast.info{background:var(--accent);color:#fff}
.json-editor{width:100%;min-height:300px;background:var(--bg);border:1px solid var(--border);color:var(--green);font-family:'Cascadia Code','Fira Code',monospace;font-size:12px;padding:16px;border-radius:var(--radius);resize:vertical;outline:none;tab-size:4}
.json-editor:focus{border-color:var(--accent)}
.provider-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.provider-chip{display:flex;align-items:center;gap:6px;padding:6px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);font-size:12px;cursor:pointer;transition:all .2s}
.provider-chip:hover{border-color:var(--accent)}
.provider-chip.selected{background:var(--accent);border-color:var(--accent);color:#fff}
.provider-chip .count{background:var(--bg);padding:1px 6px;border-radius:10px;font-size:10px;font-weight:700}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:768px){.grid-2{grid-template-columns:1fr}.field-group{grid-template-columns:1fr}.tabs{overflow-x:auto;flex-wrap:nowrap}}
.fallback-section{margin-top:16px;padding:14px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius)}
.fallback-section h4{font-size:12px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.fallback-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin-bottom:8px;align-items:start}
.fallback-row .fallback-col{display:flex;flex-direction:column;gap:4px;min-width:0}
.fallback-row .fallback-col label{font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px}
.fallback-row input{background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:var(--radius);font-size:12px;font-family:monospace;outline:none;width:100%;box-sizing:border-box}
.fallback-row input:focus{border-color:var(--accent)}
.fallback-row select{background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 8px;border-radius:var(--radius);font-size:12px;font-family:monospace;outline:none;cursor:pointer;width:100%;box-sizing:border-box}
.fallback-row select:focus{border-color:var(--accent)}
.fallback-row select option,.fallback-row select optgroup{background:var(--bg3);color:var(--text)}
.bulk-textarea{width:100%;min-height:200px;background:var(--bg);border:1px solid var(--border);color:var(--green);font-family:'Cascadia Code','Fira Code',monospace;font-size:12px;padding:12px;border-radius:var(--radius);resize:vertical;outline:none}
.bulk-textarea:focus{border-color:var(--accent)}
</style>
</head>
<body>
<div class="header">
<h1>InferencePort Lightning</h1>
<span class="subtitle">Model Editor</span>
<div class="spacer"></div>
<div class="status" id="statusBar">Connecting...</div>
</div>
<div class="tabs" id="tabs"></div>
<div class="toolbar" id="toolbar">
<input type="text" id="searchInput" placeholder="Search models by name, id, or provider...">
<div class="spacer"></div>
<button class="btn success" id="btnAdd" onclick="openAddModal()">+ Add Model</button>
<button class="btn" id="btnBulkAdd" onclick="openBulkModal()">Bulk Add</button>
<button class="btn" id="btnProviders" onclick="openProvidersModal()">Providers</button>
<button class="btn" id="btnExport" onclick="exportJSON()">Export</button>
<button class="btn primary" id="btnSave" onclick="saveFile()">Save</button>
</div>
<div class="main" id="mainContent"></div>
<!-- Add/Edit Modal -->
<div class="modal-overlay" id="modelModal">
<div class="modal">
<div class="modal-header">
<h2 id="modalTitle">Add Model</h2>
<button class="modal-close" onclick="closeModal('modelModal')">&times;</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer">
<button class="btn" onclick="closeModal('modelModal')">Cancel</button>
<button class="btn primary" id="modalSaveBtn" onclick="saveModalModel()">Save Model</button>
</div>
</div>
</div>
<!-- Bulk Add Modal -->
<div class="modal-overlay" id="bulkModal">
<div class="modal">
<div class="modal-header">
<h2>Bulk Add Models</h2>
<button class="modal-close" onclick="closeModal('bulkModal')">&times;</button>
</div>
<div class="modal-body">
<p style="color:var(--text2);margin-bottom:12px;font-size:13px">Paste a JSON array of model objects. Each object must have at minimum an <code>id</code> field.</p>
<textarea class="bulk-textarea" id="bulkInput" placeholder='[
{
"id": "my-model",
"name": "My Model",
"description": "Description here"
}
]'></textarea>
<div id="bulkPreview" style="margin-top:12px"></div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal('bulkModal')">Cancel</button>
<button class="btn" onclick="previewBulk()">Preview</button>
<button class="btn primary" onclick="applyBulk()">Add All</button>
</div>
</div>
</div>
<!-- Providers Modal -->
<div class="modal-overlay" id="providersModal">
<div class="modal">
<div class="modal-header">
<h2>Manage Providers</h2>
<button class="modal-close" onclick="closeModal('providersModal')">&times;</button>
</div>
<div class="modal-body" id="providersBody"></div>
<div class="modal-footer">
<button class="btn" onclick="closeModal('providersModal')">Close</button>
<button class="btn danger" id="btnRemoveProvider" onclick="removeSelectedProvider()" disabled>Remove Provider Models</button>
</div>
</div>
</div>
<!-- Export Modal -->
<div class="modal-overlay" id="exportModal">
<div class="modal">
<div class="modal-header">
<h2>Export JSON</h2>
<button class="modal-close" onclick="closeModal('exportModal')">&times;</button>
</div>
<div class="modal-body">
<textarea class="json-editor" id="exportOutput" readonly></textarea>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal('exportModal')">Close</button>
<button class="btn" onclick="copyExport()">Copy to Clipboard</button>
<button class="btn primary" onclick="downloadExport()">Download</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let state = {
activeTab: 'text',
data: {},
searchQuery: '',
editingModel: null,
editingIndex: null,
unsavedChanges: false,
providers: {},
endpointPresets: []
};
const FILE_CONFIG = {
text: { label: 'Text', hasLightning: true, pricingField: 'pricing_per_million_tokens', pricingLabel: '$/M tokens', inputPricingField: 'input_pricing_per_million_tokens', outputPricingField: 'output_pricing_per_million_tokens', splitPricing: true },
image: { label: 'Image', hasLightning: true, pricingField: 'pricing_per_image', pricingLabel: '$/image' },
video: { label: 'Video', hasLightning: false, pricingField: 'price_per_second', pricingLabel: '$/sec' },
audio: { label: 'Audio', hasLightning: false, pricingField: 'price_per_second', pricingLabel: '$/sec' },
'3d': { label: '3D', hasLightning: false, pricingField: 'price_per_model', pricingLabel: '$/model' }
};
const MODALITIES = ['text','image','video','audio','3d','code'];
const FEATURES = ['tools','json_mode','streaming','structured_outputs','vision','function_calling'];
const ALLOWED_IN = ['gen','payg'];
const QUANTIZATIONS = ['none','bf16','fp16','int8','int4'];
function hasField(m, field) {
return m[field] !== undefined && m[field] !== null;
}
function textPrices(m) {
const cfg = FILE_CONFIG.text;
const unified = parseFloat(m[cfg.pricingField]) || 0;
const input = hasField(m, cfg.inputPricingField) ? (parseFloat(m[cfg.inputPricingField]) || 0) : unified;
const output = hasField(m, cfg.outputPricingField) ? (parseFloat(m[cfg.outputPricingField]) || 0) : unified;
return {
input: input,
output: output,
split: hasField(m, cfg.inputPricingField) || hasField(m, cfg.outputPricingField)
};
}
function textPriceLabel(m) {
const p = textPrices(m);
if (p.input === p.output) return '$' + p.input;
return '$' + p.input + ' in · $' + p.output + ' out';
}
function priceLabel(m, fileKey) {
const cfg = FILE_CONFIG[fileKey];
if (cfg.splitPricing) return textPriceLabel(m);
return '$' + numericPrice(m, fileKey);
}
function numericPrice(m, fileKey) {
const cfg = FILE_CONFIG[fileKey];
if (cfg.splitPricing) {
const p = textPrices(m);
return (p.input + p.output) / 2;
}
return m[cfg.pricingField] || m.pricing_per_million_tokens || m.pricing_per_image || m.price_per_second || m.price_per_model || 0;
}
async function init() {
try {
const res = await fetch('/api/files');
const { files } = await res.json();
renderTabs(files);
setStatus('Connected', true);
await loadProviderPresets();
await loadFile(state.activeTab);
} catch (e) {
setStatus('Connection failed', false);
showToast('Failed to connect to server', 'error');
}
}
function setStatus(msg, ok) {
const el = document.getElementById('statusBar');
el.textContent = msg;
el.className = 'status ' + (ok ? 'ok' : 'err');
}
function renderTabs(files) {
const container = document.getElementById('tabs');
container.innerHTML = files.map(f => {
const cfg = FILE_CONFIG[f];
const count = getModelCount(f);
return `<div class="tab ${f === state.activeTab ? 'active' : ''}" onclick="switchTab('${f}')">
${cfg.label}<span class="badge" id="badge-${f}">${count}</span>
</div>`;
}).join('');
}
function getModelCount(fileKey) {
const d = state.data[fileKey];
if (!d) return 0;
if (Array.isArray(d)) return d.length;
return (d.overrides || []).length;
}
function switchTab(fileKey) {
if (state.unsavedChanges && !confirm('You have unsaved changes. Switch anyway?')) return;
state.activeTab = fileKey;
state.searchQuery = '';
document.getElementById('searchInput').value = '';
renderTabs(Object.keys(FILE_CONFIG));
loadFile(fileKey);
}
async function loadFile(fileKey) {
try {
const res = await fetch(`/api/models/${fileKey}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
state.data[fileKey] = data;
state.unsavedChanges = false;
renderModels();
buildProviderList();
if (!state.endpointPresets.length) state.endpointPresets = buildProviderPresets();
} catch (e) {
showToast(`Failed to load ${fileKey}.json: ${e.message}`, 'error');
}
}
function buildProviderList() {
const models = getModels(state.activeTab);
const providers = {};
models.forEach(m => {
const apis = Array.isArray(m.api_url) ? m.api_url : [m.api_url];
const keys = Array.isArray(m.api_key_env) ? m.api_key_env : [m.api_key_env];
apis.forEach((url, i) => {
if (!url) return;
const domain = url.replace(/^https?:\/\//, '').split('/')[0];
if (!providers[domain]) providers[domain] = { urls: new Set(), keys: new Set(), count: 0 };
providers[domain].urls.add(url);
if (keys[i]) providers[domain].keys.add(keys[i]);
providers[domain].count++;
});
});
state.providers = providers;
}
async function loadProviderPresets() {
try {
const res = await fetch('/api/providers');
const data = await res.json();
if (Array.isArray(data.presets)) {
state.endpointPresets = data.presets;
return;
}
} catch (e) { /* fall through to local scan */ }
state.endpointPresets = buildProviderPresets();
}
function buildProviderPresets() {
const seen = new Set();
const presets = [];
Object.values(state.data).forEach(d => {
if (!d) return;
const entries = Array.isArray(d) ? d : [].concat(d.lightning || [], d.overrides || []);
entries.forEach(m => {
if (!m || typeof m !== 'object') return;
const urls = Array.isArray(m.api_url) ? m.api_url : (m.api_url ? [m.api_url] : []);
const envs = Array.isArray(m.api_key_env) ? m.api_key_env : (m.api_key_env ? [m.api_key_env] : []);
urls.forEach((u, i) => {
if (!u || seen.has(u)) return;
seen.add(u);
presets.push({ url: u, key: envs[i] || '', file: '', model: m.name || m.id || '' });
});
});
});
presets.sort((a, b) => a.url.localeCompare(b.url));
return presets;
}
function providerOfUrl(u) {
return String(u).replace(/^https?:\/\//, '').split('/')[0];
}
function presetSelectHtml(idx, current) {
const presets = state.endpointPresets || [];
const groups = {};
presets.forEach(p => {
const host = providerOfUrl(p.url);
(groups[host] = groups[host] || []).push(p);
});
const matched = current && presets.some(p => p.url === current);
const selected = matched ? current : (current ? '__custom__' : '');
let html = `<select id="f_url_preset_${idx}" onchange="presetChanged(${idx})">
<option value="" ${selected === '' ? 'selected' : ''}>${presets.length ? '&mdash; Choose existing provider URL &mdash;' : '&mdash; No existing URLs found &mdash;'}</option>`;
Object.keys(groups).sort().forEach(host => {
html += `<optgroup label="${escAttr(host)}">`;
groups[host].forEach(p => {
const label = p.url + (p.key ? ' · ' + p.key : '');
html += `<option value="${escAttr(p.url)}" ${p.url === selected ? 'selected' : ''}>${escHtml(label)}</option>`;
});
html += `</optgroup>`;
});
html += `<option value="__custom__" ${selected === '__custom__' ? 'selected' : ''}>+ Type new URL</option>
</select>`;
return html;
}
function presetChanged(idx) {
const sel = document.getElementById(`f_url_preset_${idx}`);
const input = document.getElementById(`f_url_${idx}`);
const keyInput = document.getElementById(`f_key_${idx}`);
if (!sel || !input) return;
if (sel.value === '__custom__') {
input.value = '';
input.focus();
return;
}
if (!sel.value) return;
input.value = sel.value;
const preset = (state.endpointPresets || []).find(p => p.url === sel.value);
if (preset && preset.key && keyInput && !keyInput.value.trim()) keyInput.value = preset.key;
}
function urlTyped(idx) {
const sel = document.getElementById(`f_url_preset_${idx}`);
const input = document.getElementById(`f_url_${idx}`);
if (!sel || !input) return;
const value = input.value.trim();
const match = (state.endpointPresets || []).find(p => p.url === value);
sel.value = match ? match.url : (value ? '__custom__' : '');
}
function getModels(fileKey) {
const d = state.data[fileKey];
if (!d) return [];
const cfg = FILE_CONFIG[fileKey];
if (Array.isArray(d)) return d;
const overrides = d.overrides || [];
return overrides;
}
function getAllItems(fileKey) {
const d = state.data[fileKey];
if (!d) return [];
const cfg = FILE_CONFIG[fileKey];
if (Array.isArray(d)) return d.map((m, i) => ({ model: m, index: i, type: 'item' }));
const items = [];
if (d.lightning) items.push({ model: d.lightning, index: -1, type: 'lightning' });
(d.overrides || []).forEach((m, i) => items.push({ model: m, index: i, type: 'override' }));
return items;
}
function renderModels() {
const container = document.getElementById('mainContent');
const fileKey = state.activeTab;
const cfg = FILE_CONFIG[fileKey];
const items = getAllItems(fileKey);
const query = state.searchQuery.toLowerCase();
const filtered = items.filter(item => {
if (!query) return true;
const m = item.model;
return (m.id || '').toLowerCase().includes(query) ||
(m.name || '').toLowerCase().includes(query) ||
(m.description || '').toLowerCase().includes(query) ||
(Array.isArray(m.upstream_id) ? m.upstream_id.join(' ') : (m.upstream_id || '')).toLowerCase().includes(query) ||
(m.publisher_url || '').toLowerCase().includes(query);
});
// Stats
const stats = computeStats(filtered);
let html = `<div class="stats-bar">
<div class="stat"><div class="label">Total Models</div><div class="value">${filtered.length}</div></div>
<div class="stat"><div class="label">Providers</div><div class="value">${Object.keys(state.providers).length}</div></div>
<div class="stat"><div class="label">Avg ${cfg.pricingLabel}</div><div class="value">${stats.avgPrice}</div></div>
<div class="stat"><div class="label">Has Fallbacks</div><div class="value">${stats.withFallbacks}</div></div>
</div>`;
if (filtered.length === 0) {
html += `<div class="empty-state"><div class="icon">📭</div><h3>No models found</h3><p>${query ? 'Try a different search query' : 'Add some models to get started'}</p></div>`;
} else {
filtered.forEach(item => {
html += renderCard(item, fileKey);
});
}
container.innerHTML = html;
updateBadge(fileKey);
}
function computeStats(items) {
let totalPrice = 0, count = 0, withFallbacks = 0;
items.forEach(item => {
const m = item.model;
const price = numericPrice(m, state.activeTab);
if (price > 0) { totalPrice += price; count++; }
const urls = Array.isArray(m.api_url) ? m.api_url : [m.api_url];
if (urls.length > 1) withFallbacks++;
});
return {
avgPrice: count > 0 ? '$' + (totalPrice / count).toFixed(4) : '-',
withFallbacks
};
}
function renderCard(item, fileKey) {
const m = item.model;
const cfg = FILE_CONFIG[fileKey];
const price = priceLabel(m, fileKey);
const urls = Array.isArray(m.api_url) ? m.api_url : (m.api_url ? [m.api_url] : []);
const keys = Array.isArray(m.api_key_env) ? m.api_key_env : (m.api_key_env ? [m.api_key_env] : []);
const inputMods = Array.isArray(m.input_modalities) ? m.input_modalities : [];
const outputMods = Array.isArray(m.output_modalities) ? m.output_modalities : [];
const features = Array.isArray(m.supported_features) ? m.supported_features : [];
const allowed = Array.isArray(m.allowed_in) ? m.allowed_in : [];
let provider = '';
if (urls[0]) {
const domain = urls[0].replace(/^https?:\/\//, '').split('/')[0];
provider = domain;
} else if (m.provider) {
provider = m.provider;
}
const badgeColor = item.type === 'lightning' ? 'background:var(--accent)' :
item.type === 'override' ? 'background:var(--bg3);color:var(--text2)' : '';
let html = `<div class="card" id="card-${m.id}">
<div class="card-header" onclick="toggleCard('${m.id}')">
<span class="arrow" id="arrow-${m.id}">&#9654;</span>
<span class="provider-badge" style="${badgeColor}">${item.type.toUpperCase()}</span>
<span class="model-name">${m.name || m.id}</span>
<span class="model-id">${m.id}</span>
${m.publisher_url ? `<span class="provider-badge" style="background:rgba(0,184,148,.15);color:var(--green)">${m.publisher_url}</span>` : ''}
${provider ? `<span class="provider-badge" style="background:var(--bg4)">${provider}</span>` : ''}
<span class="pricing">${price}</span>
<button class="btn sm" onclick="event.stopPropagation();editModel(${item.type === 'lightning' ? -1 : item.index},'${item.type}')">Edit</button>
<button class="btn sm danger" onclick="event.stopPropagation();deleteModel(${item.type === 'lightning' ? -1 : item.index},'${item.type}')">Delete</button>
</div>
<div class="card-body" id="body-${m.id}">
${m.description ? `<p style="color:var(--text2);font-size:13px;margin-bottom:12px;line-height:1.5">${escHtml(m.description)}</p>` : ''}
<div class="field-group">
${m.publisher_url ? renderField('Publisher URL', m.publisher_url) : ''}
${m.upstream_id ? renderField('Upstream ID', Array.isArray(m.upstream_id) ? m.upstream_id.join(', ') : m.upstream_id) : ''}
${m.hugging_face_id ? renderField('HuggingFace ID', m.hugging_face_id) : ''}
${m.openrouter_slug ? renderField('OpenRouter Slug', m.openrouter_slug) : ''}
${m.context_length ? renderField('Context Length', m.context_length.toLocaleString()) : ''}
${m.max_output_length ? renderField('Max Output', m.max_output_length.toLocaleString()) : ''}
${cfg.splitPricing && textPrices(m).split ? renderField('Input $/M', '$' + textPrices(m).input) + renderField('Output $/M', '$' + textPrices(m).output) : ''}
${m.quantization ? renderField('Quantization', m.quantization) : ''}
${m.price_per_second ? renderField('Price/Second', '$' + m.price_per_second) : ''}
${m.max_duration_seconds ? renderField('Max Duration', m.max_duration_seconds + 's') : ''}
${m.resolution ? renderField('Resolution', m.resolution) : ''}
${m.type ? renderField('Type', m.type) : ''}
</div>
${inputMods.length ? renderTagSection('Input Modalities', inputMods, 'modality') : ''}
${outputMods.length ? renderTagSection('Output Modalities', outputMods, 'modality') : ''}
${features.length ? renderTagSection('Features', features, 'feature') : ''}
${allowed.length ? renderTagSection('Allowed In', allowed, 'allowed') : ''}
${urls.length ? renderFallbacks(urls, keys, m.id) : ''}
</div>
</div>`;
return html;
}
function renderField(label, value) {
return `<div class="field"><label>${label}</label><div style="padding:6px 0;font-size:13px">${escHtml(String(value))}</div></div>`;
}
function renderTagSection(label, tags, cls) {
return `<div style="margin-top:12px"><label style="font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:600;display:block;margin-bottom:6px">${label}</label>
<div class="tags">${tags.map(t => `<span class="tag ${cls}">${t}</span>`).join('')}</div></div>`;
}
function renderFallbacks(urls, keys, modelId) {
let html = `<div class="fallback-section"><h4>Fallback Chain (${urls.length} endpoint${urls.length > 1 ? 's' : ''})</h4>`;
urls.forEach((url, i) => {
html += `<div class="fallback-row">
<div class="fallback-col"><label>URL ${i + 1}</label><input value="${escAttr(url)}" readonly></div>
<div class="fallback-col"><label>Key Env ${i + 1}</label><input value="${escAttr(keys[i] || '')}" readonly></div>
</div>`;
});
html += '</div>';
return html;
}
function toggleCard(id) {
const body = document.getElementById('body-' + id);
const arrow = document.getElementById('arrow-' + id);
if (body) {
body.classList.toggle('open');
arrow.classList.toggle('open');
}
}
function editModel(index, type) {
const fileKey = state.activeTab;
const d = state.data[fileKey];
let model;
if (type === 'lightning') {
model = d.lightning;
} else if (type === 'override') {
model = d.overrides[index];
} else {
model = d[index];
}
state.editingModel = model;
state.editingIndex = index;
state.editingType = type;
openModelModal(model, fileKey, type);
}
function deleteModel(index, type) {
if (!confirm('Delete this model? This cannot be undone until you save.')) return;
const fileKey = state.activeTab;
const d = state.data[fileKey];
if (type === 'lightning') {
showToast('Cannot delete lightning config', 'error');
return;
}
if (type === 'override') {
d.overrides.splice(index, 1);
} else {
d.splice(index, 1);
}
state.unsavedChanges = true;
renderModels();
buildProviderList();
showToast('Model removed (unsaved)', 'info');
}
function fallbackUrlCell(idx, value) {
return `<div class="fallback-col"><label>API URL ${idx + 1}</label>
${presetSelectHtml(idx, value || '')}
<input id="f_url_${idx}" value="${escAttr(value || '')}" placeholder="https://... or pick above" oninput="urlTyped(${idx})"></div>`;
}
function fallbackKeyCell(idx, value) {
return `<div class="fallback-col"><label>API Key Env ${idx + 1}</label><input id="f_key_${idx}" value="${escAttr(value || '')}" placeholder="ENV_VAR"></div>`;
}
function openAddModal() {
state.editingModel = null;
state.editingIndex = null;
state.editingType = null;
const fileKey = state.activeTab;
const cfg = FILE_CONFIG[fileKey];
const newModel = { id: '', name: '', description: '', api_url: [''], api_key_env: [''], allowed_in: ['gen', 'payg'] };
if (cfg.pricingField) newModel[cfg.pricingField] = 0;
if (fileKey === 'text' || fileKey === 'image') {
newModel.input_modalities = ['text'];
newModel.output_modalities = [fileKey === 'text' ? 'text' : 'image'];
newModel.supported_features = ['streaming'];
newModel.context_length = 128000;
newModel.max_output_length = 32000;
}
if (fileKey === 'video') {
newModel.type = 'video';
newModel.output_modalities = ['video'];
newModel.max_duration_seconds = 10;
}
if (fileKey === 'audio') {
newModel.type = 'audio';
newModel.supported_parameters = ['duration'];
newModel.default_duration_seconds = 10;
}
if (fileKey === '3d') {
newModel.type = '3D';
}
openModelModal(newModel, fileKey, null);
}
function openModelModal(model, fileKey, type) {
document.getElementById('modalTitle').textContent = state.editingModel ? `Edit: ${model.name || model.id}` : 'Add Model';
const cfg = FILE_CONFIG[fileKey];
const isTextImage = fileKey === 'text' || fileKey === 'image';
const isVideo = fileKey === 'video';
const isAudio = fileKey === 'audio';
const is3d = fileKey === '3d';
const urls = Array.isArray(model.api_url) ? model.api_url : (model.api_url ? [model.api_url] : ['']);
const keys = Array.isArray(model.api_key_env) ? model.api_key_env : (model.api_key_env ? [model.api_key_env] : ['']);
const inputMods = Array.isArray(model.input_modalities) ? model.input_modalities : [];
const outputMods = Array.isArray(model.output_modalities) ? model.output_modalities : [];
const features = Array.isArray(model.supported_features) ? model.supported_features : [];
const allowed = Array.isArray(model.allowed_in) ? model.allowed_in : [];
let html = `<div class="field-group">
<div class="field"><label>ID *</label><input id="f_id" value="${escAttr(model.id || '')}" placeholder="model-id"></div>
<div class="field"><label>Name *</label><input id="f_name" value="${escAttr(model.name || '')}" placeholder="Model Display Name"></div>
<div class="field"><label>Publisher URL</label><input id="f_publisher_url" value="${escAttr(model.publisher_url || '')}" placeholder="e.g. anthropic.com, openai.com"></div>
<div class="field"><label>Upstream ID</label><input id="f_upstream_id" value="${escAttr(Array.isArray(model.upstream_id) ? model.upstream_id.join(', ') : (model.upstream_id || ''))}" placeholder="id sent to provider (comma-separate for multiples)"></div>
<div class="field"><label>Description</label><textarea id="f_description" rows="3">${escHtml(model.description || '')}</textarea></div>
${cfg.pricingField ? (cfg.splitPricing ? `
<div class="field"><label>Pricing Mode</label><select id="f_pricing_mode" onchange="togglePricingMode()">
<option value="unified">Unified (same input &amp; output)</option>
<option value="split">Separate input / output</option>
</select></div>
<div class="field" id="f_price_unified_wrap"><label>${cfg.pricingLabel} (input &amp; output)</label><input id="f_price" type="number" step="0.0001" value="${parseFloat(model[cfg.pricingField]) || 0}"></div>
<div class="field" id="f_price_input_wrap" style="display:none"><label>Input ${cfg.pricingLabel}</label><input id="f_price_input" type="number" step="0.0001" value="${parseFloat(model[cfg.inputPricingField]) || 0}"></div>
<div class="field" id="f_price_output_wrap" style="display:none"><label>Output ${cfg.pricingLabel}</label><input id="f_price_output" type="number" step="0.0001" value="${parseFloat(model[cfg.outputPricingField]) || 0}"></div>
` : `<div class="field"><label>${cfg.pricingLabel}</label><input id="f_price" type="number" step="0.0001" value="${model[cfg.pricingField] || 0}"></div>`) : ''}
${isTextImage ? `
<div class="field"><label>Context Length</label><input id="f_context_length" type="number" value="${model.context_length || 128000}"></div>
<div class="field"><label>Max Output Length</label><input id="f_max_output_length" type="number" value="${model.max_output_length || 32000}"></div>
<div class="field"><label>Quantization</label><select id="f_quantization">${QUANTIZATIONS.map(q => `<option ${model.quantization === q ? 'selected' : ''}>${q}</option>`).join('')}</select></div>
<div class="field"><label>HuggingFace ID</label><input id="f_hugging_face_id" value="${escAttr(model.hugging_face_id || '')}"></div>
<div class="field"><label>OpenRouter Slug</label><input id="f_openrouter_slug" value="${escAttr(model.openrouter_slug || '')}"></div>
` : ''}
${isVideo ? `
<div class="field"><label>Type</label><input id="f_type" value="${escAttr(model.type || 'video')}"></div>
<div class="field"><label>Provider</label><input id="f_provider" value="${escAttr(model.provider || '')}"></div>
<div class="field"><label>Max Duration (sec)</label><input id="f_max_duration" type="number" value="${model.max_duration_seconds || 10}"></div>
<div class="field"><label>Resolution</label><input id="f_resolution" value="${escAttr(model.resolution || '')}"></div>
` : ''}
${isAudio ? `
<div class="field"><label>Type</label><input id="f_type" value="${escAttr(model.type || 'audio')}"></div>
<div class="field"><label>Default Duration (sec)</label><input id="f_default_duration" type="number" value="${model.default_duration_seconds || 10}"></div>
` : ''}
${is3d ? `
<div class="field"><label>Type</label><input id="f_type" value="${escAttr(model.type || '3D')}"></div>
` : ''}
</div>`;
// Fallback endpoints
html += `<div class="fallback-section" style="margin-top:16px"><h4>Fallback Endpoints</h4><div id="fallbacksContainer">`;
const maxLen = Math.max(urls.length, keys.length, 1);
for (let i = 0; i < maxLen; i++) {
html += `<div class="fallback-row" id="fb-row-${i}">
${fallbackUrlCell(i, urls[i])}
${fallbackKeyCell(i, keys[i])}
<button class="btn sm danger" style="margin-top:18px" onclick="removeFallback(${i})">&times;</button>
</div>`;
}
html += `</div><button class="btn sm" onclick="addFallback()" style="margin-top:8px">+ Add Endpoint</button></div>`;
// Modality checkboxes
if (isTextImage) {
html += `<div class="grid-2" style="margin-top:16px">
<div><label style="font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:600;display:block;margin-bottom:8px">Input Modalities</label>
<div class="tags">${MODALITIES.map(m => `<span class="tag modality ${inputMods.includes(m) ? 'active' : ''}" onclick="toggleModalTag(this,'input','${m}')">${m}</span>`).join('')}</div></div>
<div><label style="font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:600;display:block;margin-bottom:8px">Output Modalities</label>
<div class="tags">${MODALITIES.map(m => `<span class="tag modality ${outputMods.includes(m) ? 'active' : ''}" onclick="toggleModalTag(this,'output','${m}')">${m}</span>`).join('')}</div></div>
</div>
<div style="margin-top:12px"><label style="font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:600;display:block;margin-bottom:8px">Features</label>
<div class="tags">${FEATURES.map(f => `<span class="tag feature ${features.includes(f) ? 'active' : ''}" onclick="toggleFeatureTag(this,'${f}')">${f}</span>`).join('')}</div></div>
<div style="margin-top:12px"><label style="font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:600;display:block;margin-bottom:8px">Allowed In</label>
<div class="tags">${ALLOWED_IN.map(a => `<span class="tag allowed ${allowed.includes(a) ? 'active' : ''}" onclick="toggleAllowedTag(this,'${a}')">${a}</span>`).join('')}</div></div>`;
}
document.getElementById('modalBody').innerHTML = html;
const modeSel = document.getElementById('f_pricing_mode');
if (modeSel) {
modeSel.value = textPrices(model).split ? 'split' : 'unified';
togglePricingMode();
}
document.getElementById('modelModal').classList.add('open');
}
function togglePricingMode() {
const mode = document.getElementById('f_pricing_mode');
const split = mode && mode.value === 'split';
const unifiedWrap = document.getElementById('f_price_unified_wrap');
const inputWrap = document.getElementById('f_price_input_wrap');
const outputWrap = document.getElementById('f_price_output_wrap');
if (unifiedWrap) unifiedWrap.style.display = split ? 'none' : '';
if (inputWrap) inputWrap.style.display = split ? '' : 'none';
if (outputWrap) outputWrap.style.display = split ? '' : 'none';
}
function toggleModalTag(el, type, mod) {
el.classList.toggle('active');
}
function toggleFeatureTag(el, feat) {
el.classList.toggle('active');
}
function toggleAllowedTag(el, allowed) {
el.classList.toggle('active');
}
function addFallback() {
const container = document.getElementById('fallbacksContainer');
const idx = container.children.length;
const row = document.createElement('div');
row.className = 'fallback-row';
row.id = `fb-row-${idx}`;
row.innerHTML = `
${fallbackUrlCell(idx, '')}
${fallbackKeyCell(idx, '')}
<button class="btn sm danger" style="margin-top:18px" onclick="removeFallback(${idx})">&times;</button>`;
container.appendChild(row);
}
function removeFallback(idx) {
const container = document.getElementById('fallbacksContainer');
const row = document.getElementById(`fb-row-${idx}`);
if (row) row.remove();
// Re-index
Array.from(container.children).forEach((child, i) => {
child.id = `fb-row-${i}`;
const labels = child.querySelectorAll('label');
if (labels[0]) labels[0].textContent = `API URL ${i + 1}`;
if (labels[1]) labels[1].textContent = `API Key Env ${i + 1}`;
const preset = child.querySelector('select');
if (preset) {
preset.id = `f_url_preset_${i}`;
preset.setAttribute('onchange', `presetChanged(${i})`);
}
const inputs = child.querySelectorAll('input');
if (inputs[0]) {
inputs[0].id = `f_url_${i}`;
inputs[0].setAttribute('oninput', `urlTyped(${i})`);
}
if (inputs[1]) inputs[1].id = `f_key_${i}`;
const btn = child.querySelector('button');
if (btn) btn.onclick = () => removeFallback(i);
});
}
function saveModalModel() {
const fileKey = state.activeTab;
const cfg = FILE_CONFIG[fileKey];
const d = state.data[fileKey];
const id = document.getElementById('f_id').value.trim();
const name = document.getElementById('f_name').value.trim();
if (!id || !name) { showToast('ID and Name are required', 'error'); return; }
const model = {};
model.id = id;
model.name = name;
const pubUrl = document.getElementById('f_publisher_url');
if (pubUrl && pubUrl.value.trim()) model.publisher_url = pubUrl.value.trim();
const desc = document.getElementById('f_description');
if (desc) model.description = desc.value;
const usid = document.getElementById('f_upstream_id');
if (usid) {
const val = usid.value.trim();
if (val) model.upstream_id = val.includes(',') ? val.split(',').map(s => s.trim()).filter(Boolean) : val;
}
if (cfg.pricingField) {
const modeSel = document.getElementById('f_pricing_mode');
const isSplit = cfg.splitPricing && modeSel && modeSel.value === 'split';
if (isSplit) {
const input = document.getElementById('f_price_input');
const output = document.getElementById('f_price_output');
model[cfg.inputPricingField] = parseFloat(input?.value) || 0;
model[cfg.outputPricingField] = parseFloat(output?.value) || 0;
} else {
const price = document.getElementById('f_price');
model[cfg.pricingField] = parseFloat(price?.value) || 0;
}
}
if (fileKey === 'text' || fileKey === 'image') {
model.context_length = parseInt(document.getElementById('f_context_length')?.value) || 128000;
model.max_output_length = parseInt(document.getElementById('f_max_output_length')?.value) || 32000;
model.quantization = document.getElementById('f_quantization')?.value || 'none';
model.hugging_face_id = document.getElementById('f_hugging_face_id')?.value || '';
model.openrouter_slug = document.getElementById('f_openrouter_slug')?.value || '';
// Modalities
const inputMods = [];
document.querySelectorAll('#modalBody .tag.modality').forEach(tag => {
if (tag.classList.contains('active') && tag.onclick?.toString().includes("'input'")) {
inputMods.push(tag.textContent);
}
});
const outputMods = [];
document.querySelectorAll('#modalBody .tag.modality').forEach(tag => {
if (tag.classList.contains('active') && tag.onclick?.toString().includes("'output'")) {
outputMods.push(tag.textContent);
}
});
model.input_modalities = inputMods.length ? inputMods : ['text'];
model.output_modalities = outputMods.length ? outputMods : ['text'];
// Features
const features = [];
document.querySelectorAll('#modalBody .tag.feature.active').forEach(tag => features.push(tag.textContent));
model.supported_features = features.length ? features : ['streaming'];
// Allowed
const allowed = [];
document.querySelectorAll('#modalBody .tag.allowed.active').forEach(tag => allowed.push(tag.textContent));
model.allowed_in = allowed.length ? allowed : ['gen', 'payg'];
model.supported_sampling_parameters = ['temperature', 'stop', 'max_tokens'];
}
if (fileKey === 'video') {
model.type = document.getElementById('f_type')?.value || 'video';
model.provider = document.getElementById('f_provider')?.value || '';
model.output_modalities = ['video'];
model.max_duration_seconds = parseInt(document.getElementById('f_max_duration')?.value) || 10;
model.resolution = document.getElementById('f_resolution')?.value || undefined;
model.allowed_in = ['gen', 'payg'];
if (!model.resolution) delete model.resolution;
}
if (fileKey === 'audio') {
model.type = document.getElementById('f_type')?.value || 'audio';
model.default_duration_seconds = parseInt(document.getElementById('f_default_duration')?.value) || 10;
model.supported_parameters = ['duration'];
}
if (fileKey === '3d') {
model.type = document.getElementById('f_type')?.value || '3D';
}
// Fallbacks
const urls = [], keys = [];
let i = 0;
while (document.getElementById(`f_url_${i}`)) {
const url = document.getElementById(`f_url_${i}`).value.trim();
const key = document.getElementById(`f_key_${i}`).value.trim();
if (url) urls.push(url);
if (key) keys.push(key);
i++;
}
if (urls.length) model.api_url = urls;
if (keys.length) model.api_key_env = keys;
// Save
const type = state.editingType;
const index = state.editingIndex;
if (type === 'lightning' && index === -1) {
d.lightning = model;
} else if (type === 'override') {
d.overrides[index] = model;
} else if (type === null || type === undefined) {
// Adding new
if (Array.isArray(d)) {
d.push(model);
} else {
if (!d.overrides) d.overrides = [];
d.overrides.push(model);
}
} else {
// plain array item
d[index] = model;
}
state.unsavedChanges = true;
closeModal('modelModal');
renderModels();
buildProviderList();
showToast(state.editingModel ? 'Model updated (unsaved)' : 'Model added (unsaved)', 'success');
}
function openBulkModal() {
document.getElementById('bulkInput').value = '';
document.getElementById('bulkPreview').innerHTML = '';
document.getElementById('bulkModal').classList.add('open');
}
function previewBulk() {
const raw = document.getElementById('bulkInput').value;
try {
const models = JSON.parse(raw);
if (!Array.isArray(models)) throw new Error('Must be an array');
const preview = document.getElementById('bulkPreview');
preview.innerHTML = `<p style="color:var(--green);font-size:13px">Found ${models.length} models. First 3:</p>
<pre style="background:var(--bg);padding:12px;border-radius:var(--radius);font-size:11px;overflow:auto;max-height:200px;color:var(--green)">${escHtml(JSON.stringify(models.slice(0, 3), null, 2))}</pre>`;
} catch (e) {
document.getElementById('bulkPreview').innerHTML = `<p style="color:var(--red);font-size:13px">Error: ${e.message}</p>`;
}
}
function applyBulk() {
const raw = document.getElementById('bulkInput').value;
try {
const models = JSON.parse(raw);
if (!Array.isArray(models)) throw new Error('Must be an array');
const fileKey = state.activeTab;
const d = state.data[fileKey];
models.forEach(m => {
if (!m.id) throw new Error('Each model must have an id');
if (Array.isArray(d)) {
d.push(m);
} else {
if (!d.overrides) d.overrides = [];
d.overrides.push(m);
}
});
state.unsavedChanges = true;
closeModal('bulkModal');
renderModels();
buildProviderList();
showToast(`Added ${models.length} models (unsaved)`, 'success');
} catch (e) {
showToast(`Bulk add failed: ${e.message}`, 'error');
}
}
function openProvidersModal() {
const body = document.getElementById('providersBody');
const providers = Object.entries(state.providers).sort((a, b) => b[1].count - a[1].count);
let html = `<p style="color:var(--text2);font-size:13px;margin-bottom:16px">Click a provider to select it, then remove all its models.</p>`;
html += `<div class="provider-list">`;
providers.forEach(([domain, info]) => {
html += `<div class="provider-chip" data-provider="${domain}" onclick="selectProvider(this,'${domain}')">
<span>${domain}</span>
<span class="count">${info.count}</span>
</div>`;
});
html += '</div>';
body.innerHTML = html;
state.selectedProvider = null;
document.getElementById('btnRemoveProvider').disabled = true;
document.getElementById('providersModal').classList.add('open');
}
function selectProvider(el, domain) {
document.querySelectorAll('.provider-chip').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
state.selectedProvider = domain;
document.getElementById('btnRemoveProvider').disabled = false;
}
function removeSelectedProvider() {
if (!state.selectedProvider) return;
const domain = state.selectedProvider;
if (!confirm(`Remove all models from ${domain}? This cannot be undone until you save.`)) return;
const fileKey = state.activeTab;
const d = state.data[fileKey];
function filterByProvider(models) {
return models.filter(m => {
const urls = Array.isArray(m.api_url) ? m.api_url : (m.api_url ? [m.api_url] : []);
return !urls.some(u => u && u.includes(domain));
});
}
if (Array.isArray(d)) {
state.data[fileKey] = filterByProvider(d);
} else {
d.overrides = filterByProvider(d.overrides || []);
}
state.unsavedChanges = true;
buildProviderList();
closeModal('providersModal');
renderModels();
showToast(`Removed models from ${domain} (unsaved)`, 'success');
}
function exportJSON() {
const fileKey = state.activeTab;
const data = state.data[fileKey];
document.getElementById('exportOutput').value = JSON.stringify(data, null, 4);
document.getElementById('exportModal').classList.add('open');
}
function copyExport() {
const el = document.getElementById('exportOutput');
el.select();
document.execCommand('copy');
showToast('Copied to clipboard', 'success');
}
function downloadExport() {
const fileKey = state.activeTab;
const blob = new Blob([document.getElementById('exportOutput').value], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${fileKey}.json`;
a.click();
}
async function saveFile() {
const fileKey = state.activeTab;
const data = state.data[fileKey];
try {
const res = await fetch(`/api/models/${fileKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
if (result.error) throw new Error(result.error);
state.unsavedChanges = false;
showToast(`Saved ${fileKey}.json`, 'success');
setStatus('Saved', true);
} catch (e) {
showToast(`Save failed: ${e.message}`, 'error');
}
}
function updateBadge(fileKey) {
const el = document.getElementById(`badge-${fileKey}`);
if (el) el.textContent = getModelCount(fileKey);
}
function closeModal(id) {
document.getElementById(id).classList.remove('open');
}
function showToast(msg, type) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = `toast ${type} show`;
setTimeout(() => el.classList.remove('show'), 3000);
}
function escHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function escAttr(s) {
return String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
document.getElementById('searchInput').addEventListener('input', e => {
state.searchQuery = e.target.value;
renderModels();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
});
window.addEventListener('beforeunload', e => {
if (state.unsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
init();
</script>
</body>
</html>

Xet Storage Details

Size:
53.6 kB
·
Xet hash:
89dddb65000f1ed8c071a7f7a2613e6b27ceb346e792f73ff1f418010dc41722

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.