1. 选模型后参数自适应调整: - n_gpu_layers 设为模型实际层数(而非'all') - 多卡时 split_mode 设为 tensor - tensor_split 按各卡显存比例自动计算 - 增减/切换显卡时也自动更新 tensor_split 2. 模型路径为空时红色线框标记 3. 参数值非默认时用橙色线框标记(非红非绿)
454 lines
23 KiB
JavaScript
454 lines
23 KiB
JavaScript
// ===== 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(); if (state.selectedModel) applyModelDefaults(state.selectedModel); generateCommand(); }
|
||
function removeGpuSlot(i) { state.gpuSlots.splice(i, 1); renderGpuSlots(); if (state.gpuSlots.length > 1) { if (state.selectedModel) applyModelDefaults(state.selectedModel); } else { state.paramValues['split_mode'] = state.params.find(p=>p.param_key==='split_mode')?.default_value || 'layer'; state.paramValues['tensor_split'] = state.params.find(p=>p.param_key==='tensor_split')?.default_value || ''; } generateCommand(); }
|
||
function updateGpuSlot(i, name) { const g = state.gpus.find(g => g.name === name); if (g) state.gpuSlots[i] = { ...g }; renderGpuSlots(); if (state.gpuSlots.length > 1 && state.selectedModel) applyModelDefaults(state.selectedModel); 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 model's layer count (not 'all', use actual number)
|
||
if (m.layers && m.layers > 0) {
|
||
state.paramValues['n_gpu_layers'] = String(m.layers);
|
||
}
|
||
// Multi-GPU: set split_mode to tensor, compute tensor_split by VRAM ratio
|
||
if (state.gpuSlots.length > 1) {
|
||
state.paramValues['split_mode'] = 'tensor';
|
||
updateTensorSplit();
|
||
}
|
||
// Update param UI if currently visible
|
||
renderParams();
|
||
generateCommand();
|
||
}
|
||
|
||
function updateTensorSplit() {
|
||
if (state.gpuSlots.length <= 1) return;
|
||
const totalVram = state.gpuSlots.reduce((s, g) => s + (g.vram_mb || 0), 0);
|
||
if (totalVram > 0) {
|
||
const ratios = state.gpuSlots.map(g => (g.vram_mb / totalVram).toFixed(2));
|
||
state.paramValues['tensor_split'] = ratios.join(',');
|
||
}
|
||
}
|
||
|
||
// ===== 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];
|
||
// Determine item class: empty-required (red), modified (orange), or normal
|
||
let itemClass = '';
|
||
if (p.param_key === 'model' && (!val || val.trim() === '')) {
|
||
itemClass = 'param-empty';
|
||
} else if (isParamModified(p, val)) {
|
||
itemClass = 'param-changed';
|
||
}
|
||
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 ${itemClass}" 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);
|