// ===== 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 => ``).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 => ``).join(''); return `
GPU ${i+1}显存: ${(gpu.vram_mb/1024).toFixed(1)} GB${state.gpuSlots.length > 1 ? `` : ''}
`; }).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 `
${n}${qs}
`; }).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 => ` `).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 = `
模型: ${m.name} 大小: ${m.size_gb} GB 层数: ${m.layers} 嵌入: ${m.embd} KV Heads: ${m.kv_heads} Head Dim: ${m.head_dim} 量化: ${m.quant || 'N/A'}
`; // 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 ? '⚡显存' : ''; // Show both short and long flag const shortFlag = p.short_flag || ''; const longFlag = p.long_flag || ''; let flagHtml; if (shortFlag && longFlag && shortFlag !== longFlag) { flagHtml = `${shortFlag}${longFlag}`; } else { flagHtml = `${longFlag}`; } let inp = ''; if (p.param_type === 'boolean') { inp = ``; } else if (p.param_type === 'select') { const opts = Array.isArray(p.options) ? p.options : []; inp = ``; } 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 = ``; } else { inp = ``; } const uh = p.unit ? `${p.unit}` : ''; return `
${flagHtml}${vb}${p.description}${inp}${uh}
`; } 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; // Update this item's visual state without full re-render (preserves focus) const item = document.querySelector(`.param-item[data-key="${key}"]`); if (item) { const p = state.params.find(p => p.param_key === key); if (p) { item.classList.remove('param-empty', 'param-changed'); if (p.param_key === 'model' && (!value || String(value).trim() === '')) { item.classList.add('param-empty'); } else if (isParamModified(p, value)) { item.classList.add('param-changed'); } } } // 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 => `
${h.text}
`).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 = '
解析结果:
'; 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 += `GPU: ${data._gpu_name}${cnt > 1 ? ' x'+cnt : ''}`; } } if (data._mode) { switchMode(data._mode); html += `模式: ${data._mode}`; } for (const [k, v] of Object.entries(data)) { if (k.startsWith('_')) continue; state.paramValues[k] = v; html += `${k}: ${v}`; } 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);