- 支持多版本 llama.cpp 参数 (b6310, b10068) - GPU 模式 / GPU+CPU 模式 - 多 GPU 支持 (最多4张) - 实时显存/内存估算 - 自然语言解析生成命令 - 参数分级显示 (重要/隐藏) - 仅输出非默认值参数 - 后台管理 (GPU/版本/参数 CRUD) - 模型预设 (7B-70B)
502 lines
17 KiB
JavaScript
502 lines
17 KiB
JavaScript
// ===== State =====
|
||
let state = {
|
||
versions: [],
|
||
gpus: [],
|
||
currentVersionId: null,
|
||
params: [], // param definitions for current version
|
||
paramValues: {}, // user-selected values
|
||
mode: 'gpu',
|
||
gpuSlots: [], // [{name, vram_mb, ...}]
|
||
showHidden: false,
|
||
currentTab: 'common',
|
||
binary: 'llama-server',
|
||
};
|
||
|
||
// ===== Init =====
|
||
async function init() {
|
||
await loadVersions();
|
||
await loadGpus();
|
||
// Set default version
|
||
const defaultVer = state.versions.find(v => v.version_tag === 'b10068') || state.versions[0];
|
||
if (defaultVer) {
|
||
state.currentVersionId = defaultVer.id;
|
||
document.getElementById('version-select').value = defaultVer.id;
|
||
await loadParams(defaultVer.id);
|
||
}
|
||
// Default GPU: RTX 3090
|
||
const defaultGpu = state.gpus.find(g => g.name === 'RTX 3090') || state.gpus[0];
|
||
if (defaultGpu) {
|
||
state.gpuSlots = [{ ...defaultGpu }];
|
||
renderGpuSlots();
|
||
}
|
||
renderParams();
|
||
generateCommand();
|
||
updateEstimate();
|
||
}
|
||
|
||
// ===== Load Data =====
|
||
async function loadVersions() {
|
||
const res = await fetch('/api/versions');
|
||
state.versions = await res.json();
|
||
const sel = document.getElementById('version-select');
|
||
sel.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 loadParams(versionId) {
|
||
const res = await fetch(`/api/versions/${versionId}/params`);
|
||
state.params = await res.json();
|
||
// Initialize param values with defaults
|
||
state.paramValues = {};
|
||
state.params.forEach(p => {
|
||
if (p.param_type === 'number') {
|
||
state.paramValues[p.param_key] = p.default_value;
|
||
} else if (p.param_type === 'boolean') {
|
||
state.paramValues[p.param_key] = p.default_value === 'true';
|
||
} else {
|
||
state.paramValues[p.param_key] = p.default_value || '';
|
||
}
|
||
});
|
||
renderParams();
|
||
generateCommand();
|
||
updateEstimate();
|
||
}
|
||
|
||
// ===== Version Change =====
|
||
async function onVersionChange() {
|
||
const sel = document.getElementById('version-select');
|
||
state.currentVersionId = parseInt(sel.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);
|
||
});
|
||
|
||
const memPanel = document.getElementById('memory-panel');
|
||
const gpuPanel = document.getElementById('gpu-panel');
|
||
|
||
if (mode === 'gpu_cpu') {
|
||
memPanel.classList.remove('hidden');
|
||
} else {
|
||
memPanel.classList.add('hidden');
|
||
}
|
||
|
||
generateCommand();
|
||
updateEstimate();
|
||
}
|
||
|
||
// ===== GPU Slots =====
|
||
function renderGpuSlots() {
|
||
const container = document.getElementById('gpu-slots');
|
||
container.innerHTML = state.gpuSlots.map((gpu, i) => {
|
||
const options = 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>
|
||
${options}
|
||
</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('');
|
||
|
||
const addBtn = document.getElementById('btn-add-gpu');
|
||
addBtn.style.display = state.gpuSlots.length >= 4 ? 'none' : 'block';
|
||
|
||
updateEstimate();
|
||
}
|
||
|
||
function addGpuSlot() {
|
||
if (state.gpuSlots.length >= 4) return;
|
||
const defaultGpu = state.gpus.find(g => g.name === 'RTX 3090') || state.gpus[0];
|
||
state.gpuSlots.push({ ...defaultGpu });
|
||
renderGpuSlots();
|
||
generateCommand();
|
||
}
|
||
|
||
function removeGpuSlot(index) {
|
||
state.gpuSlots.splice(index, 1);
|
||
renderGpuSlots();
|
||
generateCommand();
|
||
}
|
||
|
||
function updateGpuSlot(index, name) {
|
||
const gpu = state.gpus.find(g => g.name === name);
|
||
if (gpu) {
|
||
state.gpuSlots[index] = { ...gpu };
|
||
}
|
||
renderGpuSlots();
|
||
generateCommand();
|
||
}
|
||
|
||
// ===== Parameter Rendering =====
|
||
function switchTab(cat) {
|
||
state.currentTab = cat;
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.cat === cat);
|
||
});
|
||
renderParams();
|
||
}
|
||
|
||
function renderParams() {
|
||
const container = document.getElementById('param-container');
|
||
const params = state.params.filter(p => p.category === state.currentTab);
|
||
|
||
const importantParams = params.filter(p => p.is_important === 1);
|
||
const otherParams = params.filter(p => p.is_important === 0);
|
||
|
||
let html = '';
|
||
|
||
// Important params always visible
|
||
importantParams.forEach(p => html += renderParamItem(p, false));
|
||
|
||
// Other params
|
||
otherParams.forEach(p => {
|
||
const hidden = !state.showHidden;
|
||
html += renderParamItem(p, hidden);
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
|
||
// Show/hide toggle button
|
||
const toggleBtn = document.getElementById('toggle-advanced-btn');
|
||
if (otherParams.length > 0) {
|
||
toggleBtn.style.display = 'block';
|
||
toggleBtn.textContent = state.showHidden ? '▲ 收起更多参数' : '▼ 显示更多参数';
|
||
} else {
|
||
toggleBtn.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function renderParamItem(p, hidden) {
|
||
const val = state.paramValues[p.param_key];
|
||
const defaultVal = p.default_value;
|
||
const isModified = isParamModified(p, val);
|
||
const hiddenClass = hidden ? 'hidden-param' : '';
|
||
const modifiedClass = isModified ? 'modified' : '';
|
||
|
||
let inputHtml = '';
|
||
if (p.param_type === 'boolean') {
|
||
inputHtml = `
|
||
<div class="param-checkbox">
|
||
<input type="checkbox" ${val ? 'checked' : ''}
|
||
onchange="setParam('${p.param_key}', this.checked)">
|
||
<span>${val ? '开启' : '关闭'}</span>
|
||
</div>
|
||
`;
|
||
} else if (p.param_type === 'select') {
|
||
const options = Array.isArray(p.options) ? p.options : [];
|
||
inputHtml = `
|
||
<select onchange="setParam('${p.param_key}', this.value)">
|
||
${options.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}"` : '';
|
||
inputHtml = `
|
||
<input type="number" value="${val}" step="${step}" ${min} ${max}
|
||
onchange="setParam('${p.param_key}', this.value)"
|
||
oninput="onParamInput('${p.param_key}', this.value)">
|
||
`;
|
||
} else {
|
||
inputHtml = `
|
||
<input type="text" value="${val}"
|
||
onchange="setParam('${p.param_key}', this.value)"
|
||
oninput="onParamInput('${p.param_key}', this.value)">
|
||
`;
|
||
}
|
||
|
||
const unitHtml = p.unit ? `<span style="color:var(--text-dim);font-size:0.8em;">${p.unit}</span>` : '';
|
||
const vramBadge = p.affects_vram ? '<span style="color:var(--accent);font-size:0.7em;">⚡显存</span>' : '';
|
||
|
||
return `
|
||
<div class="param-item ${hiddenClass} ${modifiedClass}" data-key="${p.param_key}">
|
||
<div class="param-label">
|
||
<span class="param-flag">${p.short_flag || p.long_flag}</span>
|
||
${vramBadge}
|
||
${p.description}
|
||
</div>
|
||
<div class="param-desc">${p.description}</div>
|
||
${inputHtml}
|
||
${unitHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function isParamModified(p, val) {
|
||
const defaultVal = p.default_value;
|
||
if (p.param_type === 'boolean') {
|
||
return (val === true || val === 'true') !== (defaultVal === 'true');
|
||
}
|
||
return String(val) !== String(defaultVal);
|
||
}
|
||
|
||
function setParam(key, value) {
|
||
state.paramValues[key] = value;
|
||
renderParams();
|
||
generateCommand();
|
||
updateEstimate();
|
||
}
|
||
|
||
let inputTimer = null;
|
||
function onParamInput(key, value) {
|
||
state.paramValues[key] = value;
|
||
// Debounce update
|
||
if (inputTimer) clearTimeout(inputTimer);
|
||
inputTimer = setTimeout(() => {
|
||
renderParams();
|
||
generateCommand();
|
||
updateEstimate();
|
||
}, 300);
|
||
}
|
||
|
||
function toggleHiddenParams() {
|
||
state.showHidden = !state.showHidden;
|
||
renderParams();
|
||
}
|
||
|
||
// ===== Model Presets =====
|
||
function setModelPreset(type) {
|
||
const presets = {
|
||
'7b': { size: 4.1, layers: 32, embd: 4096, kv_heads: 32, head_dim: 128, heads: 32 },
|
||
'13b': { size: 7.4, layers: 40, embd: 5120, kv_heads: 40, head_dim: 128, heads: 40 },
|
||
'34b': { size: 19.5, layers: 48, embd: 8192, kv_heads: 8, head_dim: 128, heads: 64 },
|
||
'70b': { size: 38.5, layers: 80, embd: 8192, kv_heads: 8, head_dim: 128, heads: 64 },
|
||
'70b-q8': { size: 74.0, layers: 80, embd: 8192, kv_heads: 8, head_dim: 128, heads: 64 },
|
||
'70b-fp16': { size: 138.0, layers: 80, embd: 8192, kv_heads: 8, head_dim: 128, heads: 64 },
|
||
};
|
||
const p = presets[type];
|
||
if (p) {
|
||
document.getElementById('model-size-gb').value = p.size;
|
||
document.getElementById('model-layers').value = p.layers;
|
||
document.getElementById('model-embd').value = p.embd;
|
||
document.getElementById('model-kv-heads').value = p.kv_heads;
|
||
document.getElementById('model-head-dim').value = p.head_dim;
|
||
document.getElementById('model-heads').value = p.heads;
|
||
updateEstimate();
|
||
}
|
||
}
|
||
|
||
// ===== VRAM Estimation =====
|
||
async function updateEstimate() {
|
||
const params = collectParamsForEstimate();
|
||
const gpuSelections = state.gpuSlots.map(s => ({ name: s.name, vram_mb: s.vram_mb }));
|
||
|
||
const sysMemory = parseFloat(document.getElementById('sys-memory').value) || 0;
|
||
const sysMemUnit = document.getElementById('sys-memory-unit').value;
|
||
let sysMemoryGb = 0;
|
||
if (sysMemUnit === '1') sysMemoryGb = sysMemory;
|
||
else if (sysMemUnit === '2') sysMemoryGb = sysMemory / 1024;
|
||
|
||
const res = await fetch('/api/estimate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
params,
|
||
gpu_selections: gpuSelections,
|
||
mode: state.mode,
|
||
system_memory_gb: sysMemoryGb,
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
|
||
renderVramDisplay(data);
|
||
if (state.mode === 'gpu_cpu') {
|
||
renderRamDisplay(data, sysMemoryGb);
|
||
}
|
||
}
|
||
|
||
function collectParamsForEstimate() {
|
||
const params = { ...state.paramValues };
|
||
params._model_size_gb = parseFloat(document.getElementById('model-size-gb').value) || 0;
|
||
params._model_layers = parseInt(document.getElementById('model-layers').value) || 0;
|
||
params._model_embd = parseInt(document.getElementById('model-embd').value) || 0;
|
||
params._model_kv_heads = parseInt(document.getElementById('model-kv-heads').value) || 0;
|
||
params._model_head_dim = parseInt(document.getElementById('model-head-dim').value) || 0;
|
||
params._model_heads = parseInt(document.getElementById('model-heads').value) || 0;
|
||
return params;
|
||
}
|
||
|
||
function renderVramDisplay(data) {
|
||
const bar = document.getElementById('vram-bar');
|
||
const label = document.getElementById('vram-label');
|
||
const breakdown = document.getElementById('vram-breakdown');
|
||
|
||
if (!data.total_vram_available_mb || data.total_vram_available_mb === 0) {
|
||
bar.style.width = '0%';
|
||
label.textContent = '请选择GPU';
|
||
breakdown.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
const percent = data.usage_percent || 0;
|
||
bar.style.width = Math.min(percent, 100) + '%';
|
||
|
||
if (percent > 90) {
|
||
bar.className = 'vram-bar danger';
|
||
} else if (percent > 75) {
|
||
bar.className = 'vram-bar warning';
|
||
} else {
|
||
bar.className = 'vram-bar';
|
||
}
|
||
|
||
label.textContent = `VRAM: ${data.total_gb}GB / ${data.total_vram_available_gb}GB (${percent}%)`;
|
||
|
||
breakdown.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, sysMemoryGb) {
|
||
const bar = document.getElementById('ram-bar');
|
||
const label = document.getElementById('ram-label');
|
||
const breakdown = document.getElementById('ram-breakdown');
|
||
|
||
if (sysMemoryGb === 0) {
|
||
bar.style.width = '0%';
|
||
label.textContent = `内存: ${data.cpu_total_gb}GB (无上限)`;
|
||
} else {
|
||
const percent = data.cpu_usage_percent || 0;
|
||
bar.style.width = Math.min(percent, 100) + '%';
|
||
if (percent > 90) {
|
||
bar.className = 'vram-bar ram-bar danger';
|
||
} else if (percent > 75) {
|
||
bar.className = 'vram-bar ram-bar warning';
|
||
} else {
|
||
bar.className = 'vram-bar ram-bar';
|
||
}
|
||
label.textContent = `内存: ${data.cpu_total_gb}GB / ${sysMemoryGb}GB (${percent}%)`;
|
||
}
|
||
|
||
breakdown.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>
|
||
<div class="breakdown-item">
|
||
<span class="label">开销</span>
|
||
<span class="value">~0.49GB</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ===== 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();
|
||
|
||
// Apply parsed values
|
||
let resultHtml = '<div>解析结果:</div>';
|
||
|
||
// Handle GPU
|
||
if (data._gpu_name) {
|
||
const gpu = state.gpus.find(g => g.name === data._gpu_name);
|
||
if (gpu) {
|
||
if (data._gpu_count && data._gpu_count > 1) {
|
||
state.gpuSlots = [];
|
||
for (let i = 0; i < Math.min(data._gpu_count, 4); i++) {
|
||
state.gpuSlots.push({ ...gpu });
|
||
}
|
||
} else {
|
||
state.gpuSlots = [{ ...gpu }];
|
||
}
|
||
renderGpuSlots();
|
||
resultHtml += `<span class="parsed-param"><span class="key">GPU:</span> <span class="value">${data._gpu_name}${data._gpu_count > 1 ? ' x' + data._gpu_count : ''}</span></span>`;
|
||
}
|
||
}
|
||
|
||
// Handle mode
|
||
if (data._mode) {
|
||
switchMode(data._mode);
|
||
resultHtml += `<span class="parsed-param"><span class="key">模式:</span> <span class="value">${data._mode}</span></span>`;
|
||
}
|
||
|
||
// Apply other params
|
||
for (const [key, value] of Object.entries(data)) {
|
||
if (key.startsWith('_')) continue;
|
||
state.paramValues[key] = value;
|
||
resultHtml += `<span class="parsed-param"><span class="key">${key}:</span> <span class="value">${value}</span></span>`;
|
||
}
|
||
|
||
document.getElementById('nl-result').innerHTML = resultHtml;
|
||
renderParams();
|
||
generateCommand();
|
||
updateEstimate();
|
||
}
|
||
|
||
// ===== Generate Command =====
|
||
async function generateCommand() {
|
||
const params = { ...state.paramValues };
|
||
// Remove model info params
|
||
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;
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
const btn = event.target;
|
||
const originalText = btn.textContent;
|
||
btn.textContent = '✅ 已复制!';
|
||
setTimeout(() => btn.textContent = originalText, 2000);
|
||
});
|
||
}
|
||
|
||
// ===== Init on Load =====
|
||
window.addEventListener('DOMContentLoaded', init);
|