feat: v1.3.0 - 五项优化
1. 显存/内存进度条置顶显示 (sticky) - 滚动页面时始终可见 VRAM 和内存占用情况 - GPU+CPU 模式时内存条也同步显示在 sticky 区域 2. 系统内存配置合并到 GPU 配置区域内 - GPU+CPU 模式时无缝衔接在显存占用下方 - 去掉单独面板,去掉单位选择,固定为 GB - 填入 >0 即为有限制,0 为无限制 3. 参数同时显示简写和完整标志 - 如 -ngl / --n-gpu-layers 4. 生成命令区域增加智能提示 - 显存不足时提示调整参数或切换模式 - 内存不足时提示增加上限或减少 GPU 层 - 显存充足时显示确认信息 - 未选模型/GPU 时给出引导提示 5. 自然语言输入改为多行 textarea - 支持多行输入 - placeholder 说明可以自然对话描述需求
This commit is contained in:
+110
-28
@@ -6,6 +6,7 @@ let state = {
|
||||
mode: 'gpu', gpuSlots: [], showHidden: false,
|
||||
currentTab: 'common', paramSearchText: '',
|
||||
selectedModel: null, defaultQuant: 'Q4_K_M',
|
||||
lastEstimate: null,
|
||||
};
|
||||
|
||||
// ===== Init =====
|
||||
@@ -34,7 +35,6 @@ async function loadModelsGrouped() {
|
||||
state.defaultQuant = data.default_quant || 'Q4_K_M';
|
||||
state.baseModelList = Object.keys(state.modelsGrouped).sort();
|
||||
state.filteredBaseModels = state.baseModelList;
|
||||
// Also flat list for backward compat
|
||||
state.models = Object.values(state.modelsGrouped).flat();
|
||||
renderBaseModelDropdown();
|
||||
}
|
||||
@@ -49,14 +49,14 @@ async function loadParams(versionId) {
|
||||
renderParams(); generateCommand(); updateEstimate();
|
||||
}
|
||||
|
||||
// ===== Version Change =====
|
||||
async function onVersionChange() { state.currentVersionId = parseInt(document.getElementById('version-select').value); await loadParams(state.currentVersionId); }
|
||||
|
||||
// ===== Mode Switch =====
|
||||
function switchMode(mode) {
|
||||
state.mode = mode;
|
||||
document.querySelectorAll('.mode-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === mode));
|
||||
document.getElementById('memory-panel').classList.toggle('hidden', mode !== 'gpu_cpu');
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -73,49 +73,40 @@ function addGpuSlot() { if (state.gpuSlots.length >= 4) return; const dg = state
|
||||
function removeGpuSlot(i) { state.gpuSlots.splice(i, 1); renderGpuSlots(); generateCommand(); }
|
||||
function updateGpuSlot(i, name) { const g = state.gpus.find(g => g.name === name); if (g) state.gpuSlots[i] = { ...g }; renderGpuSlots(); generateCommand(); }
|
||||
|
||||
// ===== Model Selection (two-step) =====
|
||||
// ===== Model Selection =====
|
||||
function filterBaseModels() {
|
||||
const text = document.getElementById('model-search').value.toLowerCase();
|
||||
state.filteredBaseModels = state.baseModelList.filter(n => n.toLowerCase().includes(text));
|
||||
renderBaseModelDropdown();
|
||||
document.getElementById('model-dropdown').style.display = state.filteredBaseModels.length > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderBaseModelDropdown() {
|
||||
document.getElementById('model-dropdown').innerHTML = state.filteredBaseModels.map(n => {
|
||||
const quants = state.modelsGrouped[n] || [];
|
||||
const quantStr = quants.map(q => q.quant).filter(Boolean).join(', ');
|
||||
return `<div class="model-option" onclick="selectBaseModel('${n}')"><span class="model-name">${n}</span><span class="model-meta">${quantStr}</span></div>`;
|
||||
const qs = quants.map(q => q.quant).filter(Boolean).join(', ');
|
||||
return `<div class="model-option" onclick="selectBaseModel('${n}')"><span class="model-name">${n}</span><span class="model-meta">${qs}</span></div>`;
|
||||
}).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;
|
||||
// Show quant step
|
||||
document.getElementById('quant-step').classList.remove('hidden');
|
||||
// Auto-select default quant
|
||||
const defaultVariant = variants.find(v => v.quant === state.defaultQuant) || variants[0];
|
||||
renderQuantOptions(baseName, variants, defaultVariant.id);
|
||||
selectModel(defaultVariant.id);
|
||||
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 => `
|
||||
<button class="quant-btn ${v.id === selectedId ? 'active' : ''}" onclick="selectModel(${v.id})">${v.quant || 'FP16'}<span class="quant-size">${v.size_gb}GB</span></button>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function selectModel(id) {
|
||||
const m = state.models.find(m => m.id === id);
|
||||
if (!m) return;
|
||||
state.selectedModel = m;
|
||||
// Update quant buttons active state
|
||||
const variants = state.modelsGrouped[m.base_model] || [];
|
||||
renderQuantOptions(m.base_model, variants, id);
|
||||
// Show model detail
|
||||
renderQuantOptions(m.base_model, state.modelsGrouped[m.base_model] || [], id);
|
||||
document.getElementById('model-selected-info').innerHTML = `
|
||||
<div class="model-detail">
|
||||
<span class="detail-item"><b>模型:</b> ${m.name}</span>
|
||||
@@ -195,7 +186,15 @@ function renderParamItem(p) {
|
||||
const isMod = isParamModified(p, val);
|
||||
const mc = isMod ? 'modified' : '';
|
||||
const vb = p.affects_vram ? '<span class="vram-badge">⚡显存</span>' : '';
|
||||
const flag = p.short_flag || p.long_flag;
|
||||
// Show both short and long flag
|
||||
const shortFlag = p.short_flag || '';
|
||||
const longFlag = p.long_flag || '';
|
||||
let flagHtml;
|
||||
if (shortFlag && longFlag && shortFlag !== longFlag) {
|
||||
flagHtml = `<span class="param-flag">${shortFlag}</span><span class="param-flag-long">${longFlag}</span>`;
|
||||
} else {
|
||||
flagHtml = `<span class="param-flag">${longFlag}</span>`;
|
||||
}
|
||||
let inp = '';
|
||||
if (p.param_type === 'boolean') {
|
||||
inp = `<input type="checkbox" ${val ? 'checked' : ''} onchange="setParam('${p.param_key}', this.checked)">`;
|
||||
@@ -211,7 +210,7 @@ function renderParamItem(p) {
|
||||
inp = `<input type="text" value="${val}" onchange="setParam('${p.param_key}', this.value)" data-key="${p.param_key}">`;
|
||||
}
|
||||
const uh = p.unit ? `<span class="param-unit">${p.unit}</span>` : '';
|
||||
return `<div class="param-item ${mc}" data-key="${p.param_key}"><span class="param-flag">${flag}</span>${vb}<span class="param-desc-text">${p.description}</span>${inp}${uh}</div>`;
|
||||
return `<div class="param-item ${mc}" data-key="${p.param_key}">${flagHtml}${vb}<span class="param-desc-text">${p.description}</span>${inp}${uh}</div>`;
|
||||
}
|
||||
|
||||
function isParamModified(p, val) {
|
||||
@@ -234,12 +233,16 @@ 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;
|
||||
const smu = document.getElementById('sys-memory-unit').value;
|
||||
let sysGb = 0; if (smu === '1') sysGb = sm; else if (smu === '2') sysGb = sm / 1024;
|
||||
// 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);
|
||||
if (state.mode === 'gpu_cpu') renderRamDisplay(data, sysGb, isUnlimited);
|
||||
updateStickyBar(data, sysGb, isUnlimited);
|
||||
generateCommandHint(data, sysGb, isUnlimited);
|
||||
}
|
||||
|
||||
function collectParamsForEstimate() {
|
||||
@@ -265,13 +268,92 @@ function renderVramDisplay(data) {
|
||||
bd.innerHTML = `<div class="breakdown-item"><span class="label">模型权重</span><span class="value">${data.weights_gb}GB</span></div><div class="breakdown-item"><span class="label">KV缓存</span><span class="value">${data.kv_cache_gb}GB</span></div><div class="breakdown-item"><span class="label">计算/开销</span><span class="value">${(data.compute_mb/1024).toFixed(2)}GB</span></div><div class="breakdown-item"><span class="label">CUDA开销</span><span class="value">${(data.cuda_overhead_mb/1024).toFixed(2)}GB</span></div>`;
|
||||
}
|
||||
|
||||
function renderRamDisplay(data, sysGb) {
|
||||
function renderRamDisplay(data, sysGb, isUnlimited) {
|
||||
const bar = document.getElementById('ram-bar'), label = document.getElementById('ram-label'), bd = document.getElementById('ram-breakdown');
|
||||
if (sysGb === 0) { bar.style.width = '0%'; label.textContent = `内存: ${data.cpu_total_gb}GB (无上限)`; }
|
||||
else { const pct = data.cpu_usage_percent || 0; bar.style.width = Math.min(pct, 100) + '%'; bar.className = 'vram-bar ram-bar' + (pct > 90 ? ' danger' : pct > 75 ? ' warning' : ''); label.textContent = `内存: ${data.cpu_total_gb}GB / ${sysGb}GB (${pct}%)`; }
|
||||
if (isUnlimited) { bar.style.width = '0%'; label.textContent = `内存: ${data.cpu_total_gb}GB (无限制)`; }
|
||||
else {
|
||||
const pct = data.cpu_usage_percent || 0;
|
||||
bar.style.width = Math.min(pct, 100) + '%';
|
||||
bar.className = 'vram-bar ram-bar' + (pct > 90 ? ' danger' : pct > 75 ? ' warning' : '');
|
||||
label.textContent = `内存: ${data.cpu_total_gb}GB / ${sysGb}GB (${pct}%)`;
|
||||
}
|
||||
bd.innerHTML = `<div class="breakdown-item"><span class="label">CPU模型权重</span><span class="value">${data.cpu_weights_gb}GB</span></div><div class="breakdown-item"><span class="label">CPU KV缓存</span><span class="value">${(data.cpu_kv_cache_mb/1024).toFixed(2)}GB</span></div>`;
|
||||
}
|
||||
|
||||
// ===== 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 => `<div class="hint-${h.type}">${h.text}</div>`).join('');
|
||||
}
|
||||
|
||||
// ===== Natural Language =====
|
||||
async function parseNaturalLanguage() {
|
||||
const text = document.getElementById('nl-input').value;
|
||||
|
||||
Reference in New Issue
Block a user