feat: v1.2.0 - 三项优化

1. 模型选择改为两步式:先搜索选择基模型,再选择量化版本
   - 自动选中默认量化版本(Q4_K_M),后台可配置
   - 模型新增 base_model 字段,按基模型分组
   - 后台模型管理增加基模型字段

2. 参数搜索改为全局搜索
   - 搜索时跨所有分类显示匹配参数
   - 隐藏分类标签页,显示匹配数量

3. 自然语言支持 LLM 接口
   - 后台可配置 LLM API (URL/Key/Model/System Prompt)
   - 启用后优先调用 LLM 解析,失败自动回退正则解析
   - 兼容 OpenAI API 格式
This commit is contained in:
2026-07-19 22:57:38 +08:00
parent a115c5f7b4
commit 6691f37e45
7 changed files with 363 additions and 378 deletions
+150 -320
View File
@@ -1,59 +1,42 @@
// ===== State =====
let state = {
versions: [],
gpus: [],
models: [],
filteredModels: [],
selectedModel: null,
currentVersionId: null,
params: [],
paramValues: {},
mode: 'gpu',
gpuSlots: [],
showHidden: false,
currentTab: 'common',
paramSearchText: '',
versions: [], gpus: [], models: [], modelsGrouped: {},
baseModelList: [], filteredBaseModels: [],
currentVersionId: null, params: [], paramValues: {},
mode: 'gpu', gpuSlots: [], showHidden: false,
currentTab: 'common', paramSearchText: '',
selectedModel: null, defaultQuant: 'Q4_K_M',
};
// ===== Init =====
async function init() {
await loadVersions();
await loadGpus();
await loadModels();
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);
}
const defaultGpu = state.gpus.find(g => g.name === 'RTX 3090') || state.gpus[0];
if (defaultGpu) {
state.gpuSlots = [{ ...defaultGpu }];
renderGpuSlots();
}
renderParams();
generateCommand();
updateEstimate();
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();
const sel = document.getElementById('version-select');
sel.innerHTML = state.versions.map(v => `<option value="${v.id}">${v.version_tag} - ${v.description}</option>`).join('');
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 loadGpus() {
const res = await fetch('/api/gpus');
state.gpus = await res.json();
}
async function loadModels() {
const res = await fetch('/api/models');
state.models = await res.json();
state.filteredModels = state.models;
renderModelDropdown();
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;
// Also flat list for backward compat
state.models = Object.values(state.modelsGrouped).flat();
renderBaseModelDropdown();
}
async function loadParams(versionId) {
@@ -61,98 +44,67 @@ async function loadParams(versionId) {
state.params = await res.json();
state.paramValues = {};
state.params.forEach(p => {
if (p.param_type === 'boolean') {
state.paramValues[p.param_key] = p.default_value === 'true';
} else {
state.paramValues[p.param_key] = p.default_value || '';
}
state.paramValues[p.param_key] = p.param_type === 'boolean' ? (p.default_value === 'true') : (p.default_value || '');
});
renderParams();
generateCommand();
updateEstimate();
renderParams(); generateCommand(); updateEstimate();
}
// ===== Version Change =====
async function onVersionChange() {
const sel = document.getElementById('version-select');
state.currentVersionId = parseInt(sel.value);
await loadParams(state.currentVersionId);
}
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.querySelectorAll('.mode-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === mode));
document.getElementById('memory-panel').classList.toggle('hidden', mode !== 'gpu_cpu');
generateCommand();
updateEstimate();
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>
`;
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(); }
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();
}
// ===== Model Selection =====
function filterModels() {
// ===== Model Selection (two-step) =====
function filterBaseModels() {
const text = document.getElementById('model-search').value.toLowerCase();
state.filteredModels = state.models.filter(m =>
m.name.toLowerCase().includes(text) ||
(m.quant || '').toLowerCase().includes(text) ||
(m.description || '').toLowerCase().includes(text)
);
renderModelDropdown();
document.getElementById('model-dropdown').style.display = state.filteredModels.length > 0 ? 'block' : 'none';
state.filteredBaseModels = state.baseModelList.filter(n => n.toLowerCase().includes(text));
renderBaseModelDropdown();
document.getElementById('model-dropdown').style.display = state.filteredBaseModels.length > 0 ? 'block' : 'none';
}
function renderModelDropdown() {
const dd = document.getElementById('model-dropdown');
dd.innerHTML = state.filteredModels.map(m => `
<div class="model-option" onclick="selectModel(${m.id})">
<span class="model-name">${m.name}</span>
<span class="model-meta">${m.size_gb}GB | ${m.layers}L | ${m.quant || ''}</span>
</div>
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>`;
}).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);
}
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('');
}
@@ -160,19 +112,20 @@ function selectModel(id) {
const m = state.models.find(m => m.id === id);
if (!m) return;
state.selectedModel = m;
document.getElementById('model-search').value = m.name;
document.getElementById('model-dropdown').style.display = 'none';
// Update quant buttons active state
const variants = state.modelsGrouped[m.base_model] || [];
renderQuantOptions(m.base_model, variants, id);
// Show model detail
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>嵌入:</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>Attention Heads:</b> ${m.attention_heads}</span>
<span class="detail-item"><b>量化:</b> ${m.quant || 'N/A'}</span>
</div>
`;
</div>`;
updateEstimate();
}
@@ -181,25 +134,39 @@ function switchTab(cat) {
state.currentTab = cat;
state.paramSearchText = '';
document.getElementById('param-search').value = '';
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.cat === cat);
});
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 = params.filter(p =>
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)
(p.long_flag||'').toLowerCase().includes(state.paramSearchText) ||
(p.short_flag||'').toLowerCase().includes(state.paramSearchText) ||
(p.description||'').toLowerCase().includes(state.paramSearchText)
);
}
return params;
@@ -207,121 +174,72 @@ function getFilteredParams() {
function renderParams() {
const container = document.getElementById('param-container');
const allParams = getFilteredParams();
// When searching, show all matching regardless of important/hidden
const all = getFilteredParams();
if (state.paramSearchText) {
container.innerHTML = allParams.map(p => renderParamItem(p)).join('');
container.innerHTML = all.map(p => renderParamItem(p)).join('');
document.getElementById('toggle-advanced-btn').style.display = 'none';
return;
}
const importantParams = allParams.filter(p => p.is_important === 1);
const otherParams = allParams.filter(p => p.is_important === 0);
let html = importantParams.map(p => renderParamItem(p)).join('');
if (state.showHidden) {
html += otherParams.map(p => renderParamItem(p)).join('');
}
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 toggleBtn = document.getElementById('toggle-advanced-btn');
if (otherParams.length > 0 && !state.paramSearchText) {
toggleBtn.style.display = 'block';
toggleBtn.textContent = state.showHidden ? '▲ 收起更多参数' : '▼ 显示更多参数';
} else {
toggleBtn.style.display = 'none';
}
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 defaultVal = p.default_value;
const isModified = isParamModified(p, val);
const modifiedClass = isModified ? 'modified' : '';
const vramBadge = p.affects_vram ? '<span class="vram-badge">⚡显存</span>' : '';
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;
let inputHtml = '';
let inp = '';
if (p.param_type === 'boolean') {
inputHtml = `<input type="checkbox" ${val ? 'checked' : ''} onchange="setParam('${p.param_key}', this.checked)">`;
inp = `<input type="checkbox" ${val ? 'checked' : ''} onchange="setParam('${p.param_key}', this.checked)">`;
} 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>`;
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}"` : '';
inputHtml = `<input type="number" value="${val}" step="${step}" ${min} ${max}
onchange="setParam('${p.param_key}', this.value)" data-key="${p.param_key}">`;
inp = `<input type="number" value="${val}" step="${step}" ${min} ${max} onchange="setParam('${p.param_key}', this.value)" data-key="${p.param_key}">`;
} else {
inputHtml = `<input type="text" value="${val}" onchange="setParam('${p.param_key}', this.value)" data-key="${p.param_key}">`;
inp = `<input type="text" value="${val}" onchange="setParam('${p.param_key}', this.value)" data-key="${p.param_key}">`;
}
const unitHtml = p.unit ? `<span class="param-unit">${p.unit}</span>` : '';
return `
<div class="param-item ${modifiedClass}" data-key="${p.param_key}">
<span class="param-flag">${flag}</span>
${vramBadge}
<span class="param-desc-text">${p.description}</span>
${inputHtml}
${unitHtml}
</div>
`;
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>`;
}
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);
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;
// Only update modified visual state, command, and estimate — do NOT re-render
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();
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();
}
function toggleHiddenParams() { state.showHidden = !state.showHidden; renderParams(); }
// ===== 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 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;
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();
renderVramDisplay(data);
if (state.mode === 'gpu_cpu') renderRamDisplay(data, sysMemoryGb);
if (state.mode === 'gpu_cpu') renderRamDisplay(data, sysGb);
}
function collectParamsForEstimate() {
@@ -333,124 +251,49 @@ function collectParamsForEstimate() {
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;
}
} 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');
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) + '%';
bar.className = 'vram-bar' + (percent > 90 ? ' danger' : percent > 75 ? ' warning' : '');
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>
`;
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, 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) + '%';
bar.className = 'vram-bar ram-bar' + (percent > 90 ? ' danger' : percent > 75 ? ' warning' : '');
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>
`;
function renderRamDisplay(data, sysGb) {
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}%)`; }
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>`;
}
// ===== 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 res = await fetch('/api/parse-nl', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
const data = await res.json();
let resultHtml = '<div>解析结果:</div>';
let html = '<div>解析结果:</div>';
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>`;
}
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);
resultHtml += `<span class="parsed-param"><span class="key">模式:</span> <span class="value">${data._mode}</span></span>`;
}
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();
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 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;
}
@@ -458,24 +301,11 @@ async function generateCommand() {
// ===== Copy =====
function copyCommand() {
const text = document.getElementById('command-output').textContent;
// Use textarea fallback for non-HTTPS environments
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 orig = btn.textContent;
btn.textContent = '✅ 已复制!';
setTimeout(() => btn.textContent = orig, 2000);
} catch(e) {
alert('复制失败,请手动选择文本复制');
}
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);
}
// ===== Init =====
window.addEventListener('DOMContentLoaded', init);