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:
2026-07-19 23:35:20 +08:00
parent 6691f37e45
commit 3c27a205e5
3 changed files with 243 additions and 54 deletions
+110 -28
View File
@@ -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;