// ===== State =====
let state = {
versions: [], gpus: [], models: [], modelsGrouped: {},
baseModelList: [], filteredBaseModels: [],
currentVersionId: null, params: [], paramValues: {},
mode: 'gpu', gpuSlots: [], showHidden: false,
currentTab: 'common', paramSearchText: '',
selectedModel: null, defaultQuant: 'Q4_K_M',
lastEstimate: null,
};
// ===== Init =====
async function init() {
await loadVersions();
await loadGpus();
await loadModelsGrouped();
await loadNlDefaultText();
const dv = state.versions.find(v => v.version_tag === 'b10068') || state.versions[0];
if (dv) { state.currentVersionId = dv.id; document.getElementById('version-select').value = dv.id; await loadParams(dv.id); }
const dg = state.gpus.find(g => g.name === 'RTX 3090') || state.gpus[0];
if (dg) { state.gpuSlots = [{ ...dg }]; renderGpuSlots(); }
renderParams(); generateCommand(); updateEstimate();
}
async function loadNlDefaultText() {
const res = await fetch('/api/settings/public');
const data = await res.json();
const ta = document.getElementById('nl-input');
if (data.nl_default_text) {
ta.placeholder = data.nl_default_text;
}
}
// ===== Load Data =====
async function loadVersions() {
const res = await fetch('/api/versions'); state.versions = await res.json();
document.getElementById('version-select').innerHTML = state.versions.map(v => `${v.version_tag} - ${v.description} `).join('');
}
async function loadGpus() { const res = await fetch('/api/gpus'); state.gpus = await res.json(); }
async function loadModelsGrouped() {
const res = await fetch('/api/models/grouped');
const data = await res.json();
state.modelsGrouped = data.models || {};
state.defaultQuant = data.default_quant || 'Q4_K_M';
state.baseModelList = Object.keys(state.modelsGrouped).sort();
state.filteredBaseModels = state.baseModelList;
state.models = Object.values(state.modelsGrouped).flat();
renderBaseModelDropdown();
}
async function loadParams(versionId) {
const res = await fetch(`/api/versions/${versionId}/params`);
state.params = await res.json();
state.paramValues = {};
state.params.forEach(p => {
state.paramValues[p.param_key] = p.param_type === 'boolean' ? (p.default_value === 'true') : (p.default_value || '');
});
renderParams(); generateCommand(); updateEstimate();
}
async function onVersionChange() { state.currentVersionId = parseInt(document.getElementById('version-select').value); await loadParams(state.currentVersionId); }
function switchMode(mode) {
state.mode = mode;
document.querySelectorAll('.mode-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === mode));
const showRam = mode === 'gpu_cpu';
document.getElementById('inline-memory-config').classList.toggle('hidden', !showRam);
document.getElementById('sticky-ram-row').classList.toggle('hidden', !showRam);
generateCommand(); updateEstimate();
}
// ===== GPU Slots =====
function renderGpuSlots() {
document.getElementById('gpu-slots').innerHTML = state.gpuSlots.map((gpu, i) => {
const opts = state.gpus.map(g => `${g.name} (${(g.vram_mb/1024).toFixed(0)}GB) `).join('');
return `
GPU ${i+1} -- 选择GPU -- ${opts}显存: ${(gpu.vram_mb/1024).toFixed(1)} GB ${state.gpuSlots.length > 1 ? `× ` : ''}
`;
}).join('');
document.getElementById('btn-add-gpu').style.display = state.gpuSlots.length >= 4 ? 'none' : 'block';
updateEstimate();
}
function addGpuSlot() { if (state.gpuSlots.length >= 4) return; const dg = state.gpus.find(g => g.name === 'RTX 3090') || state.gpus[0]; state.gpuSlots.push({ ...dg }); renderGpuSlots(); if (state.selectedModel) applyModelDefaults(state.selectedModel); generateCommand(); }
function removeGpuSlot(i) { state.gpuSlots.splice(i, 1); renderGpuSlots(); if (state.gpuSlots.length > 1) { if (state.selectedModel) applyModelDefaults(state.selectedModel); } else { state.paramValues['split_mode'] = state.params.find(p=>p.param_key==='split_mode')?.default_value || 'layer'; state.paramValues['tensor_split'] = state.params.find(p=>p.param_key==='tensor_split')?.default_value || ''; } generateCommand(); }
function updateGpuSlot(i, name) { const g = state.gpus.find(g => g.name === name); if (g) state.gpuSlots[i] = { ...g }; renderGpuSlots(); if (state.gpuSlots.length > 1 && state.selectedModel) applyModelDefaults(state.selectedModel); generateCommand(); }
// ===== Model Selection =====
function filterBaseModels() {
const text = document.getElementById('model-search').value.toLowerCase();
if (!text) {
// Show top 5 when input is empty (on focus)
state.filteredBaseModels = state.baseModelList.slice(0, 5);
} else {
state.filteredBaseModels = state.baseModelList.filter(n => n.toLowerCase().includes(text));
}
renderBaseModelDropdown();
document.getElementById('model-dropdown').style.display = state.filteredBaseModels.length > 0 ? 'block' : 'none';
}
function onModelSearchFocus() {
if (!document.getElementById('model-search').value) {
state.filteredBaseModels = state.baseModelList.slice(0, 5);
renderBaseModelDropdown();
document.getElementById('model-dropdown').style.display = 'block';
}
}
function onModelSearchBlur() {
// Delay to allow click on option
setTimeout(() => { document.getElementById('model-dropdown').style.display = 'none'; }, 200);
}
function renderBaseModelDropdown() {
document.getElementById('model-dropdown').innerHTML = state.filteredBaseModels.map(n => {
const quants = state.modelsGrouped[n] || [];
const qs = quants.map(q => q.quant).filter(Boolean).join(', ');
return `${n} ${qs}
`;
}).join('');
}
function selectBaseModel(baseName) {
document.getElementById('model-search').value = baseName;
document.getElementById('model-dropdown').style.display = 'none';
const variants = state.modelsGrouped[baseName] || [];
if (variants.length === 0) return;
document.getElementById('quant-step').classList.remove('hidden');
const dv = variants.find(v => v.quant === state.defaultQuant) || variants[0];
renderQuantOptions(baseName, variants, dv.id);
selectModel(dv.id);
}
function renderQuantOptions(baseName, variants, selectedId) {
document.getElementById('quant-options').innerHTML = variants.map(v => `
${v.quant || 'FP16'}${v.size_gb}GB
`).join('');
}
function selectModel(id) {
const m = state.models.find(m => m.id === id);
if (!m) return;
state.selectedModel = m;
renderQuantOptions(m.base_model, state.modelsGrouped[m.base_model] || [], id);
document.getElementById('model-selected-info').innerHTML = `
模型: ${m.name}
大小: ${m.size_gb} GB
层数: ${m.layers}
嵌入: ${m.embd}
KV Heads: ${m.kv_heads}
Head Dim: ${m.head_dim}
量化: ${m.quant || 'N/A'}
`;
// Auto-adjust params based on selected model
applyModelDefaults(m);
updateEstimate();
}
function applyModelDefaults(m) {
// Set ctx_size to model's default context if available
if (m.default_ctx && m.default_ctx > 0) {
state.paramValues['ctx_size'] = String(m.default_ctx);
}
// Set n_gpu_layers to model's layer count (not 'all', use actual number)
if (m.layers && m.layers > 0) {
state.paramValues['n_gpu_layers'] = String(m.layers);
}
// Multi-GPU: set split_mode to tensor, compute tensor_split by VRAM ratio
if (state.gpuSlots.length > 1) {
state.paramValues['split_mode'] = 'tensor';
updateTensorSplit();
}
// Update param UI if currently visible
renderParams();
generateCommand();
}
function updateTensorSplit() {
if (state.gpuSlots.length <= 1) return;
const totalVram = state.gpuSlots.reduce((s, g) => s + (g.vram_mb || 0), 0);
if (totalVram > 0) {
const ratios = state.gpuSlots.map(g => (g.vram_mb / totalVram).toFixed(2));
state.paramValues['tensor_split'] = ratios.join(',');
}
}
// ===== Parameter Rendering =====
function switchTab(cat) {
state.currentTab = cat;
state.paramSearchText = '';
document.getElementById('param-search').value = '';
document.getElementById('search-hint').textContent = '';
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.cat === cat));
document.getElementById('param-tabs').style.display = '';
renderParams();
}
function onParamSearch() {
state.paramSearchText = document.getElementById('param-search').value.toLowerCase();
const hint = document.getElementById('search-hint');
if (state.paramSearchText) {
const count = state.params.filter(p =>
p.param_key.toLowerCase().includes(state.paramSearchText) ||
(p.long_flag||'').toLowerCase().includes(state.paramSearchText) ||
(p.short_flag||'').toLowerCase().includes(state.paramSearchText) ||
(p.description||'').toLowerCase().includes(state.paramSearchText)
).length;
hint.textContent = `找到 ${count} 个匹配参数`;
document.getElementById('param-tabs').style.display = 'none';
} else {
hint.textContent = '';
document.getElementById('param-tabs').style.display = '';
}
renderParams();
}
function getFilteredParams() {
// 'modified' is a special tab showing all modified params
if (state.currentTab === 'modified') {
return state.params.filter(p => isParamModified(p, state.paramValues[p.param_key]));
}
let params = state.params.filter(p => p.category === state.currentTab);
if (state.paramSearchText) {
params = state.params.filter(p =>
p.param_key.toLowerCase().includes(state.paramSearchText) ||
(p.long_flag||'').toLowerCase().includes(state.paramSearchText) ||
(p.short_flag||'').toLowerCase().includes(state.paramSearchText) ||
(p.description||'').toLowerCase().includes(state.paramSearchText)
);
}
return params;
}
function renderParams() {
const container = document.getElementById('param-container');
const all = getFilteredParams();
// For 'modified' tab, show all (no important/hidden distinction)
if (state.currentTab === 'modified' || state.paramSearchText) {
container.innerHTML = all.map(p => renderParamItem(p)).join('');
document.getElementById('toggle-advanced-btn').style.display = 'none';
return;
}
const imp = all.filter(p => p.is_important === 1);
const other = all.filter(p => p.is_important === 0);
let html = imp.map(p => renderParamItem(p)).join('');
if (state.showHidden) html += other.map(p => renderParamItem(p)).join('');
container.innerHTML = html;
const btn = document.getElementById('toggle-advanced-btn');
if (other.length > 0 && !state.paramSearchText) { btn.style.display = 'block'; btn.textContent = state.showHidden ? '▲ 收起更多参数' : '▼ 显示更多参数'; }
else btn.style.display = 'none';
}
function renderParamItem(p) {
const val = state.paramValues[p.param_key];
// Determine item class: empty-required (red), modified (orange), or normal
let itemClass = '';
if (p.param_key === 'model' && (!val || val.trim() === '')) {
itemClass = 'param-empty';
} else if (isParamModified(p, val)) {
itemClass = 'param-changed';
}
const vb = p.affects_vram ? '⚡显存 ' : '';
// Show both short and long flag
const shortFlag = p.short_flag || '';
const longFlag = p.long_flag || '';
let flagHtml;
if (shortFlag && longFlag && shortFlag !== longFlag) {
flagHtml = `${shortFlag} ${longFlag} `;
} else {
flagHtml = `${longFlag} `;
}
let inp = '';
if (p.param_type === 'boolean') {
inp = ` `;
} else if (p.param_type === 'select') {
const opts = Array.isArray(p.options) ? p.options : [];
inp = `${opts.map(o => `${o} `).join('')} `;
} else if (p.param_type === 'number') {
const step = p.step || 'any';
const min = p.min_value !== null ? `min="${p.min_value}"` : '';
const max = p.max_value !== null ? `max="${p.max_value}"` : '';
inp = ` `;
} else {
inp = ` `;
}
const uh = p.unit ? `${p.unit} ` : '';
return `${flagHtml}${vb}${p.description} ${inp}${uh}
`;
}
function isParamModified(p, val) {
const dv = p.default_value;
if (p.param_type === 'boolean') return (val===true||val==='true') !== (dv==='true');
return String(val) !== String(dv);
}
function setParam(key, value) {
state.paramValues[key] = value;
// Update this item's visual state without full re-render (preserves focus)
const item = document.querySelector(`.param-item[data-key="${key}"]`);
if (item) {
const p = state.params.find(p => p.param_key === key);
if (p) {
item.classList.remove('param-empty', 'param-changed');
if (p.param_key === 'model' && (!value || String(value).trim() === '')) {
item.classList.add('param-empty');
} else if (isParamModified(p, value)) {
item.classList.add('param-changed');
}
}
}
// If on 'modified' tab, re-render to update the list
if (state.currentTab === 'modified') renderParams();
generateCommand(); updateEstimate();
}
function toggleHiddenParams() { state.showHidden = !state.showHidden; renderParams(); }
// ===== VRAM Estimation =====
async function updateEstimate() {
const params = collectParamsForEstimate();
const gpuSel = state.gpuSlots.map(s => ({ name: s.name, vram_mb: s.vram_mb }));
const sm = parseFloat(document.getElementById('sys-memory').value) || 0;
// No unit select anymore - always GB, 0 = unlimited
const sysGb = sm > 0 ? sm : 0;
const isUnlimited = sm === 0;
const res = await fetch('/api/estimate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ params, gpu_selections: gpuSel, mode: state.mode, system_memory_gb: sysGb }) });
const data = await res.json();
state.lastEstimate = data;
renderVramDisplay(data);
if (state.mode === 'gpu_cpu') renderRamDisplay(data, sysGb, isUnlimited);
updateStickyBar(data, sysGb, isUnlimited);
generateCommandHint(data, sysGb, isUnlimited);
}
function collectParamsForEstimate() {
const params = { ...state.paramValues };
if (state.selectedModel) {
params._model_size_gb = state.selectedModel.size_gb;
params._model_layers = state.selectedModel.layers;
params._model_embd = state.selectedModel.embd;
params._model_kv_heads = state.selectedModel.kv_heads;
params._model_head_dim = state.selectedModel.head_dim;
params._model_heads = state.selectedModel.attention_heads;
} else { params._model_size_gb = 0; params._model_layers = 0; params._model_embd = 0; params._model_kv_heads = 0; params._model_head_dim = 0; params._model_heads = 0; }
return params;
}
function renderVramDisplay(data) {
// Update sticky bar instead of inline display
updateStickyBar(data, parseFloat(document.getElementById('sys-memory').value) || 0, (parseFloat(document.getElementById('sys-memory').value) || 0) === 0);
}
function renderRamDisplay(data, sysGb, isUnlimited) {
// Update sticky ram bar instead of inline display
// (sticky bar is already updated in updateStickyBar)
}
// ===== Sticky top bar =====
function updateStickyBar(data, sysGb, isUnlimited) {
const vr = document.getElementById('sticky-vram-row');
const rr = document.getElementById('sticky-ram-row');
// VRAM
if (data.total_vram_available_mb) {
const pct = data.usage_percent || 0;
const bar = document.getElementById('sticky-vram-bar');
bar.style.width = Math.min(pct, 100) + '%';
bar.className = 'sticky-bar' + (pct > 90 ? ' danger' : pct > 75 ? ' warning' : '');
document.getElementById('sticky-vram-text').textContent = `VRAM: ${data.total_gb}GB / ${data.total_vram_available_gb}GB (${pct}%)`;
} else {
document.getElementById('sticky-vram-text').textContent = 'VRAM: 请选择GPU';
}
// RAM
if (state.mode === 'gpu_cpu') {
rr.classList.remove('hidden');
if (isUnlimited) {
document.getElementById('sticky-ram-bar').style.width = '0%';
document.getElementById('sticky-ram-text').textContent = `内存: ${data.cpu_total_gb}GB (无限制)`;
} else {
const pct = data.cpu_usage_percent || 0;
const bar = document.getElementById('sticky-ram-bar');
bar.style.width = Math.min(pct, 100) + '%';
bar.className = 'sticky-bar ram-sticky-bar' + (pct > 90 ? ' danger' : pct > 75 ? ' warning' : '');
document.getElementById('sticky-ram-text').textContent = `内存: ${data.cpu_total_gb}GB / ${sysGb}GB (${pct}%)`;
}
} else {
rr.classList.add('hidden');
}
}
// ===== Command Hint =====
function generateCommandHint(data, sysGb, isUnlimited) {
const hint = document.getElementById('command-hint');
let hints = [];
// Check VRAM
if (data.usage_percent && data.usage_percent > 100) {
hints.push({ type: 'error', text: `⚠️ 显存不足!预计需要 ${data.total_gb}GB,但仅有 ${data.total_vram_available_gb}GB。建议:减少 GPU 层数(n_gpu_layers)、减小上下文(ctx_size)、使用更低量化版本,或切换到 GPU+CPU 模式。` });
} else if (data.usage_percent && data.usage_percent > 90) {
hints.push({ type: 'warning', text: `⚡ 显存接近上限 (${data.usage_percent}%),可能存在 OOM 风险。建议适当减小上下文或 GPU 层数。` });
} else if (data.usage_percent && data.usage_percent > 75) {
hints.push({ type: 'info', text: `ℹ️ 显存使用率 ${data.usage_percent}%,留有余量但不多。` });
} else if (state.selectedModel && data.usage_percent && data.usage_percent <= 75) {
hints.push({ type: 'ok', text: `✅ 显存充足,预计占用 ${data.usage_percent}%。` });
}
// Check RAM (GPU+CPU mode)
if (state.mode === 'gpu_cpu' && !isUnlimited) {
if (data.cpu_usage_percent && data.cpu_usage_percent > 100) {
hints.push({ type: 'error', text: `⚠️ 系统内存不足!预计需要 ${data.cpu_total_gb}GB,但上限仅 ${sysGb}GB。建议:增加内存上限、减少 GPU 层数让更多权重留在 GPU、或减小上下文。` });
} else if (data.cpu_usage_percent && data.cpu_usage_percent > 90) {
hints.push({ type: 'warning', text: `⚡ 内存使用率 ${data.cpu_usage_percent}%,接近上限。` });
}
}
// No model selected
if (!state.selectedModel) {
hints.push({ type: 'info', text: '💡 请在上方选择模型,以便进行准确的显存估算。' });
}
// No GPU selected
if (state.gpuSlots.length === 0 || !state.gpuSlots[0].name) {
hints.push({ type: 'info', text: '💡 请选择 GPU 显卡。' });
}
if (hints.length === 0) {
hints.push({ type: 'ok', text: '✅ 配置看起来没问题,可以复制使用。' });
}
hint.innerHTML = hints.map(h => `${h.text}
`).join('');
}
// ===== Natural Language =====
async function parseNaturalLanguage() {
const text = document.getElementById('nl-input').value;
if (!text.trim()) return;
const res = await fetch('/api/parse-nl', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
const data = await res.json();
let html = '解析结果:
';
if (data._gpu_name) {
const g = state.gpus.find(g => g.name === data._gpu_name);
if (g) { const cnt = data._gpu_count > 1 ? Math.min(data._gpu_count, 4) : 1; state.gpuSlots = []; for (let i = 0; i < cnt; i++) state.gpuSlots.push({ ...g }); renderGpuSlots(); html += `GPU: ${data._gpu_name}${cnt > 1 ? ' x'+cnt : ''} `; }
}
if (data._mode) { switchMode(data._mode); html += `模式: ${data._mode} `; }
for (const [k, v] of Object.entries(data)) { if (k.startsWith('_')) continue; state.paramValues[k] = v; html += `${k}: ${v} `; }
document.getElementById('nl-result').innerHTML = html;
renderParams(); generateCommand(); updateEstimate();
}
// ===== Generate Command =====
async function generateCommand() {
const params = { ...state.paramValues };
Object.keys(params).forEach(k => { if (k.startsWith('_')) delete params[k]; });
const res = await fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version_id: state.currentVersionId, params, mode: state.mode, gpu_selections: state.gpuSlots, binary: document.getElementById('binary-select').value }) });
const data = await res.json();
document.getElementById('command-output').textContent = data.command;
}
// ===== Copy =====
function copyCommand() {
const text = document.getElementById('command-output').textContent;
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.select();
try {
document.execCommand('copy');
const btn = document.getElementById('copy-btn');
btn.textContent = '✅ 已复制';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = '📋 复制'; btn.classList.remove('copied'); }, 2000);
} catch(e) { alert('复制失败,请手动选择文本复制'); }
document.body.removeChild(ta);
}
window.addEventListener('DOMContentLoaded', init);