Files
llama-cmd-gen/static/js/main.js
T
hz4th_coder 26d7ee0b66 feat: v1.4.0 - 五项优化
1. 模型选择: 点击搜索框弹出top5模型,支持搜索过滤
2. 选模型后自动调整参数: ctx_size设为模型默认上下文,n_gpu_layers设为all
   - models表新增 default_ctx 字段,各模型预设上下文值
3. 参数配置新增「已改参数」标签页
   - 放在最右边,绿色样式区别于其他标签
   - 显示所有被修改过(非默认值)的参数
4. 复制按钮移到命令文本框左上角悬空
5. 自然语言默认提示文本可在后台管理中编辑
   - 新增 /api/settings/public 接口
   - 后台系统设置新增 nl_default_text 配置项
2026-07-20 00:05:18 +08:00

434 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();
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 => `<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();
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 `<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>`;
// 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 'all' for GPU mode
if (state.mode === 'gpu') {
state.paramValues['n_gpu_layers'] = 'all';
}
// Update param UI if currently visible
renderParams();
generateCommand();
}
// ===== 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];
const mc = '';
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;
// 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 => `<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.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);