Files
llama-cmd-gen/static/js/main.js
T
hz4th_coder 3c27a205e5 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 说明可以自然对话描述需求
2026-07-19 23:35:20 +08:00

394 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===== 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();
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();
}
// ===== 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 => `<option value="${v.id}">${v.version_tag} - ${v.description}</option>`).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 => `<option value="${g.name}" ${g.name === gpu.name ? 'selected' : ''}>${g.name} (${(g.vram_mb/1024).toFixed(0)}GB)</option>`).join('');
return `<div class="gpu-slot"><span class="gpu-index">GPU ${i+1}</span><select onchange="updateGpuSlot(${i}, this.value)"><option value="">-- 选择GPU --</option>${opts}</select><span class="vram-info">显存: ${(gpu.vram_mb/1024).toFixed(1)} GB</span>${state.gpuSlots.length > 1 ? `<button class="btn-remove" onclick="removeGpuSlot(${i})">×</button>` : ''}</div>`;
}).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(); generateCommand(); }
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 =====
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 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;
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 => `
<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;
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>
<span class="detail-item"><b>大小:</b> ${m.size_gb} GB</span>
<span class="detail-item"><b>层数:</b> ${m.layers}</span>
<span class="detail-item"><b>嵌入:</b> ${m.embd}</span>
<span class="detail-item"><b>KV Heads:</b> ${m.kv_heads}</span>
<span class="detail-item"><b>Head Dim:</b> ${m.head_dim}</span>
<span class="detail-item"><b>量化:</b> ${m.quant || 'N/A'}</span>
</div>`;
updateEstimate();
}
// ===== 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() {
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();
if (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];
const isMod = isParamModified(p, val);
const mc = isMod ? 'modified' : '';
const vb = p.affects_vram ? '<span class="vram-badge">⚡显存</span>' : '';
// 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)">`;
} else if (p.param_type === 'select') {
const opts = Array.isArray(p.options) ? p.options : [];
inp = `<select onchange="setParam('${p.param_key}', this.value)">${opts.map(o => `<option value="${o}" ${String(val)===String(o) ? 'selected':''}>${o}</option>`).join('')}</select>`;
} 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 = `<input type="number" value="${val}" step="${step}" ${min} ${max} onchange="setParam('${p.param_key}', this.value)" data-key="${p.param_key}">`;
} else {
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}">${flagHtml}${vb}<span class="param-desc-text">${p.description}</span>${inp}${uh}</div>`;
}
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;
const item = document.querySelector(`.param-item[data-key="${key}"]`);
if (item) { const p = state.params.find(p => p.param_key === key); if (p && isParamModified(p, value)) item.classList.add('modified'); else item.classList.remove('modified'); }
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) {
const bar = document.getElementById('vram-bar'), label = document.getElementById('vram-label'), bd = document.getElementById('vram-breakdown');
if (!data.total_vram_available_mb) { bar.style.width = '0%'; label.textContent = '请选择GPU'; bd.innerHTML = ''; return; }
const pct = data.usage_percent || 0;
bar.style.width = Math.min(pct, 100) + '%';
bar.className = 'vram-bar' + (pct > 90 ? ' danger' : pct > 75 ? ' warning' : '');
label.textContent = `VRAM: ${data.total_gb}GB / ${data.total_vram_available_gb}GB (${pct}%)`;
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, isUnlimited) {
const bar = document.getElementById('ram-bar'), label = document.getElementById('ram-label'), bd = document.getElementById('ram-breakdown');
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;
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 = '<div>解析结果:</div>';
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 += `<span class="parsed-param"><span class="key">GPU:</span> <span class="value">${data._gpu_name}${cnt > 1 ? ' x'+cnt : ''}</span></span>`; }
}
if (data._mode) { switchMode(data._mode); html += `<span class="parsed-param"><span class="key">模式:</span> <span class="value">${data._mode}</span></span>`; }
for (const [k, v] of Object.entries(data)) { if (k.startsWith('_')) continue; state.paramValues[k] = v; html += `<span class="parsed-param"><span class="key">${k}:</span> <span class="value">${v}</span></span>`; }
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.querySelector('.btn-copy'); const o = btn.textContent; btn.textContent = '✅ 已复制!'; setTimeout(() => btn.textContent = o, 2000); } catch(e) { alert('复制失败,请手动选择文本复制'); }
document.body.removeChild(ta);
}
window.addEventListener('DOMContentLoaded', init);