fix: v1.1.0 - 修复5个问题

1. 首页移除后台链接,后台需密码登录
2. 参数输入不再丢失焦点(停止oninput重建DOM)
3. 每个参数单行布局 + 搜索功能
4. 复制命令使用execCommand兼容非HTTPS
5. 模型选择器替代手动输入,26个预设模型,后台可增删改

- 新增 models 数据表
- 新增 /api/models 公开读取接口
- 新增 /api/admin/models CRUD 接口
- 新增 admin 登录/登出/检查接口
- admin 页面加登录界面
- admin 新增模型管理标签页
- CORS 支持 credentials
This commit is contained in:
2026-07-19 18:49:05 +08:00
parent a407032b4d
commit 5bfb74480b
8 changed files with 826 additions and 1205 deletions
+163 -183
View File
@@ -2,28 +2,30 @@
let state = {
versions: [],
gpus: [],
models: [],
filteredModels: [],
selectedModel: null,
currentVersionId: null,
params: [], // param definitions for current version
paramValues: {}, // user-selected values
params: [],
paramValues: {},
mode: 'gpu',
gpuSlots: [], // [{name, vram_mb, ...}]
gpuSlots: [],
showHidden: false,
currentTab: 'common',
binary: 'llama-server',
paramSearchText: '',
};
// ===== Init =====
async function init() {
await loadVersions();
await loadGpus();
// Set default version
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);
}
// Default GPU: RTX 3090
const defaultGpu = state.gpus.find(g => g.name === 'RTX 3090') || state.gpus[0];
if (defaultGpu) {
state.gpuSlots = [{ ...defaultGpu }];
@@ -47,15 +49,19 @@ async function loadGpus() {
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 loadParams(versionId) {
const res = await fetch(`/api/versions/${versionId}/params`);
state.params = await res.json();
// Initialize param values with defaults
state.paramValues = {};
state.params.forEach(p => {
if (p.param_type === 'number') {
state.paramValues[p.param_key] = p.default_value;
} else if (p.param_type === 'boolean') {
if (p.param_type === 'boolean') {
state.paramValues[p.param_key] = p.default_value === 'true';
} else {
state.paramValues[p.param_key] = p.default_value || '';
@@ -79,16 +85,7 @@ function switchMode(mode) {
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
const memPanel = document.getElementById('memory-panel');
const gpuPanel = document.getElementById('gpu-panel');
if (mode === 'gpu_cpu') {
memPanel.classList.remove('hidden');
} else {
memPanel.classList.add('hidden');
}
document.getElementById('memory-panel').classList.toggle('hidden', mode !== 'gpu_cpu');
generateCommand();
updateEstimate();
}
@@ -112,10 +109,7 @@ function renderGpuSlots() {
</div>
`;
}).join('');
const addBtn = document.getElementById('btn-add-gpu');
addBtn.style.display = state.gpuSlots.length >= 4 ? 'none' : 'block';
document.getElementById('btn-add-gpu').style.display = state.gpuSlots.length >= 4 ? 'none' : 'block';
updateEstimate();
}
@@ -135,45 +129,105 @@ function removeGpuSlot(index) {
function updateGpuSlot(index, name) {
const gpu = state.gpus.find(g => g.name === name);
if (gpu) {
state.gpuSlots[index] = { ...gpu };
}
if (gpu) state.gpuSlots[index] = { ...gpu };
renderGpuSlots();
generateCommand();
}
// ===== Model Selection =====
function filterModels() {
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';
}
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>
`).join('');
}
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';
document.getElementById('model-selected-info').innerHTML = `
<div class="model-detail">
<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>Attention Heads:</b> ${m.attention_heads}</span>
<span class="detail-item"><b>量化:</b> ${m.quant || 'N/A'}</span>
</div>
`;
updateEstimate();
}
// ===== Parameter Rendering =====
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);
});
renderParams();
}
function onParamSearch() {
state.paramSearchText = document.getElementById('param-search').value.toLowerCase();
renderParams();
}
function getFilteredParams() {
let params = state.params.filter(p => p.category === state.currentTab);
if (state.paramSearchText) {
params = 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 params = state.params.filter(p => p.category === state.currentTab);
const allParams = getFilteredParams();
const importantParams = params.filter(p => p.is_important === 1);
const otherParams = params.filter(p => p.is_important === 0);
// When searching, show all matching regardless of important/hidden
if (state.paramSearchText) {
container.innerHTML = allParams.map(p => renderParamItem(p)).join('');
document.getElementById('toggle-advanced-btn').style.display = 'none';
return;
}
let html = '';
const importantParams = allParams.filter(p => p.is_important === 1);
const otherParams = allParams.filter(p => p.is_important === 0);
// Important params always visible
importantParams.forEach(p => html += renderParamItem(p, false));
// Other params
otherParams.forEach(p => {
const hidden = !state.showHidden;
html += renderParamItem(p, hidden);
});
let html = importantParams.map(p => renderParamItem(p)).join('');
if (state.showHidden) {
html += otherParams.map(p => renderParamItem(p)).join('');
}
container.innerHTML = html;
// Show/hide toggle button
const toggleBtn = document.getElementById('toggle-advanced-btn');
if (otherParams.length > 0) {
if (otherParams.length > 0 && !state.paramSearchText) {
toggleBtn.style.display = 'block';
toggleBtn.textContent = state.showHidden ? '▲ 收起更多参数' : '▼ 显示更多参数';
} else {
@@ -181,57 +235,39 @@ function renderParams() {
}
}
function renderParamItem(p, hidden) {
function renderParamItem(p) {
const val = state.paramValues[p.param_key];
const defaultVal = p.default_value;
const isModified = isParamModified(p, val);
const hiddenClass = hidden ? 'hidden-param' : '';
const modifiedClass = isModified ? 'modified' : '';
const vramBadge = p.affects_vram ? '<span class="vram-badge">⚡显存</span>' : '';
const flag = p.short_flag || p.long_flag;
let inputHtml = '';
if (p.param_type === 'boolean') {
inputHtml = `
<div class="param-checkbox">
<input type="checkbox" ${val ? 'checked' : ''}
onchange="setParam('${p.param_key}', this.checked)">
<span>${val ? '开启' : '关闭'}</span>
</div>
`;
inputHtml = `<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>
`;
inputHtml = `<select onchange="setParam('${p.param_key}', this.value)">
${options.map(o => `<option value="${o}" ${String(val) === String(o) ? 'selected' : ''}>${o}</option>`).join('')}
</select>`;
} else if (p.param_type === 'number') {
const step = p.step || 'any';
const min = p.min_value !== null ? `min="${p.min_value}"` : '';
const max = p.max_value !== null ? `max="${p.max_value}"` : '';
inputHtml = `
<input type="number" value="${val}" step="${step}" ${min} ${max}
onchange="setParam('${p.param_key}', this.value)"
oninput="onParamInput('${p.param_key}', this.value)">
`;
inputHtml = `<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)"
oninput="onParamInput('${p.param_key}', this.value)">
`;
inputHtml = `<input type="text" value="${val}" onchange="setParam('${p.param_key}', this.value)" data-key="${p.param_key}">`;
}
const unitHtml = p.unit ? `<span style="color:var(--text-dim);font-size:0.8em;">${p.unit}</span>` : '';
const vramBadge = p.affects_vram ? '<span style="color:var(--accent);font-size:0.7em;">⚡显存</span>' : '';
const unitHtml = p.unit ? `<span class="param-unit">${p.unit}</span>` : '';
return `
<div class="param-item ${hiddenClass} ${modifiedClass}" data-key="${p.param_key}">
<div class="param-label">
<span class="param-flag">${p.short_flag || p.long_flag}</span>
${vramBadge}
${p.description}
</div>
<div class="param-desc">${p.description}</div>
<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>
@@ -248,50 +284,25 @@ function isParamModified(p, val) {
function setParam(key, value) {
state.paramValues[key] = value;
renderParams();
// 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();
}
let inputTimer = null;
function onParamInput(key, value) {
state.paramValues[key] = value;
// Debounce update
if (inputTimer) clearTimeout(inputTimer);
inputTimer = setTimeout(() => {
renderParams();
generateCommand();
updateEstimate();
}, 300);
}
function toggleHiddenParams() {
state.showHidden = !state.showHidden;
renderParams();
}
// ===== Model Presets =====
function setModelPreset(type) {
const presets = {
'7b': { size: 4.1, layers: 32, embd: 4096, kv_heads: 32, head_dim: 128, heads: 32 },
'13b': { size: 7.4, layers: 40, embd: 5120, kv_heads: 40, head_dim: 128, heads: 40 },
'34b': { size: 19.5, layers: 48, embd: 8192, kv_heads: 8, head_dim: 128, heads: 64 },
'70b': { size: 38.5, layers: 80, embd: 8192, kv_heads: 8, head_dim: 128, heads: 64 },
'70b-q8': { size: 74.0, layers: 80, embd: 8192, kv_heads: 8, head_dim: 128, heads: 64 },
'70b-fp16': { size: 138.0, layers: 80, embd: 8192, kv_heads: 8, head_dim: 128, heads: 64 },
};
const p = presets[type];
if (p) {
document.getElementById('model-size-gb').value = p.size;
document.getElementById('model-layers').value = p.layers;
document.getElementById('model-embd').value = p.embd;
document.getElementById('model-kv-heads').value = p.kv_heads;
document.getElementById('model-head-dim').value = p.head_dim;
document.getElementById('model-heads').value = p.heads;
updateEstimate();
}
}
// ===== VRAM Estimation =====
async function updateEstimate() {
const params = collectParamsForEstimate();
@@ -306,29 +317,30 @@ async function updateEstimate() {
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,
})
body: JSON.stringify({ params, gpu_selections: gpuSelections, mode: state.mode, system_memory_gb: sysMemoryGb })
});
const data = await res.json();
renderVramDisplay(data);
if (state.mode === 'gpu_cpu') {
renderRamDisplay(data, sysMemoryGb);
}
if (state.mode === 'gpu_cpu') renderRamDisplay(data, sysMemoryGb);
}
function collectParamsForEstimate() {
const params = { ...state.paramValues };
params._model_size_gb = parseFloat(document.getElementById('model-size-gb').value) || 0;
params._model_layers = parseInt(document.getElementById('model-layers').value) || 0;
params._model_embd = parseInt(document.getElementById('model-embd').value) || 0;
params._model_kv_heads = parseInt(document.getElementById('model-kv-heads').value) || 0;
params._model_head_dim = parseInt(document.getElementById('model-head-dim').value) || 0;
params._model_heads = parseInt(document.getElementById('model-heads').value) || 0;
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;
}
@@ -346,34 +358,14 @@ function renderVramDisplay(data) {
const percent = data.usage_percent || 0;
bar.style.width = Math.min(percent, 100) + '%';
if (percent > 90) {
bar.className = 'vram-bar danger';
} else if (percent > 75) {
bar.className = 'vram-bar warning';
} else {
bar.className = 'vram-bar';
}
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>
<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>
`;
}
@@ -388,29 +380,13 @@ function renderRamDisplay(data, sysMemoryGb) {
} else {
const percent = data.cpu_usage_percent || 0;
bar.style.width = Math.min(percent, 100) + '%';
if (percent > 90) {
bar.className = 'vram-bar ram-bar danger';
} else if (percent > 75) {
bar.className = 'vram-bar ram-bar warning';
} else {
bar.className = 'vram-bar ram-bar';
}
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>
<div class="breakdown-item">
<span class="label">开销</span>
<span class="value">~0.49GB</span>
</div>
<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>
`;
}
@@ -426,18 +402,14 @@ async function parseNaturalLanguage() {
});
const data = await res.json();
// Apply parsed values
let resultHtml = '<div>解析结果:</div>';
// Handle GPU
if (data._gpu_name) {
const gpu = state.gpus.find(g => g.name === data._gpu_name);
if (gpu) {
if (data._gpu_count && data._gpu_count > 1) {
state.gpuSlots = [];
for (let i = 0; i < Math.min(data._gpu_count, 4); i++) {
state.gpuSlots.push({ ...gpu });
}
for (let i = 0; i < Math.min(data._gpu_count, 4); i++) state.gpuSlots.push({ ...gpu });
} else {
state.gpuSlots = [{ ...gpu }];
}
@@ -446,13 +418,11 @@ async function parseNaturalLanguage() {
}
}
// Handle mode
if (data._mode) {
switchMode(data._mode);
resultHtml += `<span class="parsed-param"><span class="key">模式:</span> <span class="value">${data._mode}</span></span>`;
}
// Apply other params
for (const [key, value] of Object.entries(data)) {
if (key.startsWith('_')) continue;
state.paramValues[key] = value;
@@ -468,7 +438,6 @@ async function parseNaturalLanguage() {
// ===== Generate Command =====
async function generateCommand() {
const params = { ...state.paramValues };
// Remove model info params
Object.keys(params).forEach(k => { if (k.startsWith('_')) delete params[k]; });
const res = await fetch('/api/generate', {
@@ -489,13 +458,24 @@ async function generateCommand() {
// ===== Copy =====
function copyCommand() {
const text = document.getElementById('command-output').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = event.target;
const originalText = btn.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 = originalText, 2000);
});
setTimeout(() => btn.textContent = orig, 2000);
} catch(e) {
alert('复制失败,请手动选择文本复制');
}
document.body.removeChild(ta);
}
// ===== Init on Load =====
// ===== Init =====
window.addEventListener('DOMContentLoaded', init);