2026-07-19 18:08:36 +08:00
|
|
|
|
// ===== State =====
|
|
|
|
|
|
let state = {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
versions: [], gpus: [], models: [], modelsGrouped: {},
|
|
|
|
|
|
baseModelList: [], filteredBaseModels: [],
|
|
|
|
|
|
currentVersionId: null, params: [], paramValues: {},
|
|
|
|
|
|
mode: 'gpu', gpuSlots: [], showHidden: false,
|
|
|
|
|
|
currentTab: 'common', paramSearchText: '',
|
|
|
|
|
|
selectedModel: null, defaultQuant: 'Q4_K_M',
|
2026-07-19 23:35:20 +08:00
|
|
|
|
lastEstimate: null,
|
2026-07-19 18:08:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Init =====
|
|
|
|
|
|
async function init() {
|
|
|
|
|
|
await loadVersions();
|
|
|
|
|
|
await loadGpus();
|
2026-07-19 22:57:38 +08:00
|
|
|
|
await loadModelsGrouped();
|
2026-07-20 00:05:18 +08:00
|
|
|
|
await loadNlDefaultText();
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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();
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 00:05:18 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 18:08:36 +08:00
|
|
|
|
// ===== Load Data =====
|
|
|
|
|
|
async function loadVersions() {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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('');
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
2026-07-19 22:57:38 +08:00
|
|
|
|
async function loadGpus() { const res = await fetch('/api/gpus'); state.gpus = await res.json(); }
|
2026-07-19 18:08:36 +08:00
|
|
|
|
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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();
|
2026-07-19 18:49:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 18:08:36 +08:00
|
|
|
|
async function loadParams(versionId) {
|
|
|
|
|
|
const res = await fetch(`/api/versions/${versionId}/params`);
|
|
|
|
|
|
state.params = await res.json();
|
|
|
|
|
|
state.paramValues = {};
|
|
|
|
|
|
state.params.forEach(p => {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
state.paramValues[p.param_key] = p.param_type === 'boolean' ? (p.default_value === 'true') : (p.default_value || '');
|
2026-07-19 18:08:36 +08:00
|
|
|
|
});
|
2026-07-19 22:57:38 +08:00
|
|
|
|
renderParams(); generateCommand(); updateEstimate();
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 22:57:38 +08:00
|
|
|
|
async function onVersionChange() { state.currentVersionId = parseInt(document.getElementById('version-select').value); await loadParams(state.currentVersionId); }
|
2026-07-19 18:08:36 +08:00
|
|
|
|
|
|
|
|
|
|
function switchMode(mode) {
|
|
|
|
|
|
state.mode = mode;
|
2026-07-19 22:57:38 +08:00
|
|
|
|
document.querySelectorAll('.mode-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === mode));
|
2026-07-19 23:35:20 +08:00
|
|
|
|
const showRam = mode === 'gpu_cpu';
|
|
|
|
|
|
document.getElementById('inline-memory-config').classList.toggle('hidden', !showRam);
|
|
|
|
|
|
document.getElementById('sticky-ram-row').classList.toggle('hidden', !showRam);
|
2026-07-19 22:57:38 +08:00
|
|
|
|
generateCommand(); updateEstimate();
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== GPU Slots =====
|
|
|
|
|
|
function renderGpuSlots() {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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>`;
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}).join('');
|
2026-07-19 18:49:05 +08:00
|
|
|
|
document.getElementById('btn-add-gpu').style.display = state.gpuSlots.length >= 4 ? 'none' : 'block';
|
2026-07-19 18:08:36 +08:00
|
|
|
|
updateEstimate();
|
|
|
|
|
|
}
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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(); }
|
2026-07-19 18:08:36 +08:00
|
|
|
|
|
2026-07-19 23:35:20 +08:00
|
|
|
|
// ===== Model Selection =====
|
2026-07-19 22:57:38 +08:00
|
|
|
|
function filterBaseModels() {
|
2026-07-19 18:49:05 +08:00
|
|
|
|
const text = document.getElementById('model-search').value.toLowerCase();
|
2026-07-20 00:05:18 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-07-19 22:57:38 +08:00
|
|
|
|
renderBaseModelDropdown();
|
|
|
|
|
|
document.getElementById('model-dropdown').style.display = state.filteredBaseModels.length > 0 ? 'block' : 'none';
|
2026-07-19 18:49:05 +08:00
|
|
|
|
}
|
2026-07-20 00:05:18 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-19 22:57:38 +08:00
|
|
|
|
function renderBaseModelDropdown() {
|
|
|
|
|
|
document.getElementById('model-dropdown').innerHTML = state.filteredBaseModels.map(n => {
|
|
|
|
|
|
const quants = state.modelsGrouped[n] || [];
|
2026-07-19 23:35:20 +08:00
|
|
|
|
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>`;
|
2026-07-19 22:57:38 +08:00
|
|
|
|
}).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');
|
2026-07-19 23:35:20 +08:00
|
|
|
|
const dv = variants.find(v => v.quant === state.defaultQuant) || variants[0];
|
|
|
|
|
|
renderQuantOptions(baseName, variants, dv.id);
|
|
|
|
|
|
selectModel(dv.id);
|
2026-07-19 22:57:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
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>
|
2026-07-19 18:49:05 +08:00
|
|
|
|
`).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
function selectModel(id) {
|
|
|
|
|
|
const m = state.models.find(m => m.id === id);
|
|
|
|
|
|
if (!m) return;
|
|
|
|
|
|
state.selectedModel = m;
|
2026-07-19 23:35:20 +08:00
|
|
|
|
renderQuantOptions(m.base_model, state.modelsGrouped[m.base_model] || [], id);
|
2026-07-19 18:49:05 +08:00
|
|
|
|
document.getElementById('model-selected-info').innerHTML = `
|
|
|
|
|
|
<div class="model-detail">
|
2026-07-19 22:57:38 +08:00
|
|
|
|
<span class="detail-item"><b>模型:</b> ${m.name}</span>
|
2026-07-19 18:49:05 +08:00
|
|
|
|
<span class="detail-item"><b>大小:</b> ${m.size_gb} GB</span>
|
|
|
|
|
|
<span class="detail-item"><b>层数:</b> ${m.layers}</span>
|
2026-07-19 22:57:38 +08:00
|
|
|
|
<span class="detail-item"><b>嵌入:</b> ${m.embd}</span>
|
2026-07-19 18:49:05 +08:00
|
|
|
|
<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>
|
2026-07-19 22:57:38 +08:00
|
|
|
|
</div>`;
|
2026-07-20 00:05:18 +08:00
|
|
|
|
// Auto-adjust params based on selected model
|
|
|
|
|
|
applyModelDefaults(m);
|
2026-07-19 18:49:05 +08:00
|
|
|
|
updateEstimate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-20 00:05:18 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 18:08:36 +08:00
|
|
|
|
// ===== Parameter Rendering =====
|
|
|
|
|
|
function switchTab(cat) {
|
|
|
|
|
|
state.currentTab = cat;
|
2026-07-19 18:49:05 +08:00
|
|
|
|
state.paramSearchText = '';
|
|
|
|
|
|
document.getElementById('param-search').value = '';
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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 = '';
|
2026-07-19 18:08:36 +08:00
|
|
|
|
renderParams();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 18:49:05 +08:00
|
|
|
|
function onParamSearch() {
|
|
|
|
|
|
state.paramSearchText = document.getElementById('param-search').value.toLowerCase();
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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 = '';
|
|
|
|
|
|
}
|
2026-07-19 18:49:05 +08:00
|
|
|
|
renderParams();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getFilteredParams() {
|
2026-07-20 00:05:18 +08:00
|
|
|
|
// '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]));
|
|
|
|
|
|
}
|
2026-07-19 18:49:05 +08:00
|
|
|
|
let params = state.params.filter(p => p.category === state.currentTab);
|
|
|
|
|
|
if (state.paramSearchText) {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
params = state.params.filter(p =>
|
2026-07-19 18:49:05 +08:00
|
|
|
|
p.param_key.toLowerCase().includes(state.paramSearchText) ||
|
2026-07-19 22:57:38 +08:00
|
|
|
|
(p.long_flag||'').toLowerCase().includes(state.paramSearchText) ||
|
|
|
|
|
|
(p.short_flag||'').toLowerCase().includes(state.paramSearchText) ||
|
|
|
|
|
|
(p.description||'').toLowerCase().includes(state.paramSearchText)
|
2026-07-19 18:49:05 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return params;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 18:08:36 +08:00
|
|
|
|
function renderParams() {
|
|
|
|
|
|
const container = document.getElementById('param-container');
|
2026-07-19 22:57:38 +08:00
|
|
|
|
const all = getFilteredParams();
|
2026-07-20 00:05:18 +08:00
|
|
|
|
// For 'modified' tab, show all (no important/hidden distinction)
|
|
|
|
|
|
if (state.currentTab === 'modified' || state.paramSearchText) {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
container.innerHTML = all.map(p => renderParamItem(p)).join('');
|
2026-07-19 18:49:05 +08:00
|
|
|
|
document.getElementById('toggle-advanced-btn').style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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('');
|
2026-07-19 18:08:36 +08:00
|
|
|
|
container.innerHTML = html;
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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';
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 18:49:05 +08:00
|
|
|
|
function renderParamItem(p) {
|
2026-07-19 18:08:36 +08:00
|
|
|
|
const val = state.paramValues[p.param_key];
|
2026-07-19 23:48:18 +08:00
|
|
|
|
const mc = '';
|
2026-07-19 22:57:38 +08:00
|
|
|
|
const vb = p.affects_vram ? '<span class="vram-badge">⚡显存</span>' : '';
|
2026-07-19 23:35:20 +08:00
|
|
|
|
// 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>`;
|
|
|
|
|
|
}
|
2026-07-19 22:57:38 +08:00
|
|
|
|
let inp = '';
|
2026-07-19 18:08:36 +08:00
|
|
|
|
if (p.param_type === 'boolean') {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
inp = `<input type="checkbox" ${val ? 'checked' : ''} onchange="setParam('${p.param_key}', this.checked)">`;
|
2026-07-19 18:08:36 +08:00
|
|
|
|
} else if (p.param_type === 'select') {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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>`;
|
2026-07-19 18:08:36 +08:00
|
|
|
|
} 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}"` : '';
|
2026-07-19 22:57:38 +08:00
|
|
|
|
inp = `<input type="number" value="${val}" step="${step}" ${min} ${max} onchange="setParam('${p.param_key}', this.value)" data-key="${p.param_key}">`;
|
2026-07-19 18:08:36 +08:00
|
|
|
|
} else {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
inp = `<input type="text" value="${val}" onchange="setParam('${p.param_key}', this.value)" data-key="${p.param_key}">`;
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
2026-07-19 22:57:38 +08:00
|
|
|
|
const uh = p.unit ? `<span class="param-unit">${p.unit}</span>` : '';
|
2026-07-19 23:35:20 +08:00
|
|
|
|
return `<div class="param-item ${mc}" data-key="${p.param_key}">${flagHtml}${vb}<span class="param-desc-text">${p.description}</span>${inp}${uh}</div>`;
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isParamModified(p, val) {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
const dv = p.default_value;
|
|
|
|
|
|
if (p.param_type === 'boolean') return (val===true||val==='true') !== (dv==='true');
|
|
|
|
|
|
return String(val) !== String(dv);
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setParam(key, value) {
|
|
|
|
|
|
state.paramValues[key] = value;
|
2026-07-20 00:05:18 +08:00
|
|
|
|
// If on 'modified' tab, re-render to update the list
|
|
|
|
|
|
if (state.currentTab === 'modified') renderParams();
|
2026-07-19 22:57:38 +08:00
|
|
|
|
generateCommand(); updateEstimate();
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 22:57:38 +08:00
|
|
|
|
function toggleHiddenParams() { state.showHidden = !state.showHidden; renderParams(); }
|
2026-07-19 18:08:36 +08:00
|
|
|
|
|
|
|
|
|
|
// ===== VRAM Estimation =====
|
|
|
|
|
|
async function updateEstimate() {
|
|
|
|
|
|
const params = collectParamsForEstimate();
|
2026-07-19 22:57:38 +08:00
|
|
|
|
const gpuSel = state.gpuSlots.map(s => ({ name: s.name, vram_mb: s.vram_mb }));
|
|
|
|
|
|
const sm = parseFloat(document.getElementById('sys-memory').value) || 0;
|
2026-07-19 23:35:20 +08:00
|
|
|
|
// No unit select anymore - always GB, 0 = unlimited
|
|
|
|
|
|
const sysGb = sm > 0 ? sm : 0;
|
|
|
|
|
|
const isUnlimited = sm === 0;
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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 }) });
|
2026-07-19 18:08:36 +08:00
|
|
|
|
const data = await res.json();
|
2026-07-19 23:35:20 +08:00
|
|
|
|
state.lastEstimate = data;
|
2026-07-19 18:08:36 +08:00
|
|
|
|
renderVramDisplay(data);
|
2026-07-19 23:35:20 +08:00
|
|
|
|
if (state.mode === 'gpu_cpu') renderRamDisplay(data, sysGb, isUnlimited);
|
|
|
|
|
|
updateStickyBar(data, sysGb, isUnlimited);
|
|
|
|
|
|
generateCommandHint(data, sysGb, isUnlimited);
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function collectParamsForEstimate() {
|
|
|
|
|
|
const params = { ...state.paramValues };
|
2026-07-19 18:49:05 +08:00
|
|
|
|
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;
|
2026-07-19 22:57:38 +08:00
|
|
|
|
} 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; }
|
2026-07-19 18:08:36 +08:00
|
|
|
|
return params;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderVramDisplay(data) {
|
2026-07-19 23:48:18 +08:00
|
|
|
|
// Update sticky bar instead of inline display
|
|
|
|
|
|
updateStickyBar(data, parseFloat(document.getElementById('sys-memory').value) || 0, (parseFloat(document.getElementById('sys-memory').value) || 0) === 0);
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 23:35:20 +08:00
|
|
|
|
function renderRamDisplay(data, sysGb, isUnlimited) {
|
2026-07-19 23:48:18 +08:00
|
|
|
|
// Update sticky ram bar instead of inline display
|
|
|
|
|
|
// (sticky bar is already updated in updateStickyBar)
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 23:35:20 +08:00
|
|
|
|
// ===== 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('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 18:08:36 +08:00
|
|
|
|
// ===== Natural Language =====
|
|
|
|
|
|
async function parseNaturalLanguage() {
|
|
|
|
|
|
const text = document.getElementById('nl-input').value;
|
|
|
|
|
|
if (!text.trim()) return;
|
2026-07-19 22:57:38 +08:00
|
|
|
|
const res = await fetch('/api/parse-nl', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
|
2026-07-19 18:08:36 +08:00
|
|
|
|
const data = await res.json();
|
2026-07-19 22:57:38 +08:00
|
|
|
|
let html = '<div>解析结果:</div>';
|
2026-07-19 18:08:36 +08:00
|
|
|
|
if (data._gpu_name) {
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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>`; }
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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();
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Generate Command =====
|
|
|
|
|
|
async function generateCommand() {
|
|
|
|
|
|
const params = { ...state.paramValues };
|
|
|
|
|
|
Object.keys(params).forEach(k => { if (k.startsWith('_')) delete params[k]; });
|
2026-07-19 22:57:38 +08:00
|
|
|
|
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 }) });
|
2026-07-19 18:08:36 +08:00
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
document.getElementById('command-output').textContent = data.command;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Copy =====
|
|
|
|
|
|
function copyCommand() {
|
|
|
|
|
|
const text = document.getElementById('command-output').textContent;
|
2026-07-19 18:49:05 +08:00
|
|
|
|
const ta = document.createElement('textarea');
|
2026-07-19 22:57:38 +08:00
|
|
|
|
ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
|
|
|
|
|
document.body.appendChild(ta); ta.select();
|
2026-07-19 23:48:18 +08:00
|
|
|
|
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('复制失败,请手动选择文本复制'); }
|
2026-07-19 18:49:05 +08:00
|
|
|
|
document.body.removeChild(ta);
|
2026-07-19 18:08:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('DOMContentLoaded', init);
|