Files
llm-proxy/templates/models.html
T

504 lines
25 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模型管理 - LLM Proxy</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style>
.gradient-bg { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); }
.cap-chip { transition: all 0.15s; cursor: pointer; }
.cap-chip.on { background: #6366f1; color: #fff; }
.cap-chip.off { background: #f3f4f6; color: #6b7280; }
.cap-chip.off:hover { background: #e5e7eb; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="flex">
<aside class="w-64 bg-slate-800 min-h-screen fixed left-0 top-0">
<div class="p-6">
<h1 class="text-white text-xl font-bold flex items-center gap-2">
<i class="ri-route-line text-2xl text-purple-400"></i>
LLM Proxy
</h1>
<p class="text-slate-400 text-sm mt-1">后台管理</p>
</div>
<nav class="mt-6">
<a href="/admin" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
<i class="ri-dashboard-line"></i><span>仪表盘</span>
</a>
<a href="/admin/providers" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
<i class="ri-server-line"></i><span>提供商管理</span>
</a>
<a href="/admin/models" class="flex items-center gap-3 px-6 py-3 bg-slate-700 text-white">
<i class="ri-cpu-line"></i><span>模型管理</span>
</a>
<a href="/admin/auto-profiles" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
<i class="ri-shuffle-line"></i><span>Auto配置</span>
</a>
<a href="/admin/chat" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
<i class="ri-chat-3-line"></i><span>对话</span>
</a>
<a href="/admin/logs" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
<i class="ri-file-list-line"></i><span>日志查看</span>
</a>
<a href="/admin/config" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
<i class="ri-settings-3-line"></i><span>系统配置</span>
</a>
</nav>
</aside>
<main class="ml-64 flex-1 p-8">
<!-- 顶部操作栏 -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">模型管理</h1>
<p class="text-gray-500 text-sm mt-1">为每个模型配置能力标签(点击可切换),AUTO配置按能力自动筛选模型</p>
</div>
<div class="flex gap-3">
<select id="providerFilter" onchange="renderModels()" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500">
<option value="">全部提供商</option>
</select>
<button onclick="showAddModelModal()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90 transition">
<i class="ri-add-line mr-1"></i> 添加模型
</button>
</div>
</div>
<!-- 能力图例 -->
<div id="capabilityLegend" class="flex flex-wrap gap-2 mb-4"></div>
<!-- 模型表格 -->
<div class="bg-white rounded-xl border border-gray-100 overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-500">模型名称</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-500">提供商</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-500">能力标签</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-500">默认</th>
<th class="px-6 py-3 text-left text-sm font-medium text-gray-500">操作</th>
</tr>
</thead>
<tbody id="modelTable">
<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">加载中...</td></tr>
</tbody>
</table>
</div>
<!-- 模型别名管理 -->
<div class="bg-white rounded-xl border border-gray-100 p-6 mt-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800">模型别名</h2>
<button onclick="showAddAliasModal()" class="px-3 py-1.5 bg-indigo-500 text-white rounded-lg text-sm hover:bg-indigo-600">
<i class="ri-add-line mr-1"></i> 添加别名
</button>
</div>
<div id="aliasList" class="grid grid-cols-2 md:grid-cols-3 gap-2">
<p class="text-gray-500 col-span-full">加载中...</p>
</div>
</div>
</main>
</div>
<!-- 添加模型弹窗 -->
<div id="addModelModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
<div class="p-6 border-b flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-800">添加模型</h2>
<button onclick="closeAddModelModal()" class="text-gray-400 hover:text-gray-600">
<i class="ri-close-line text-2xl"></i>
</button>
</div>
<div class="p-6 space-y-4 overflow-auto flex-1">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">所属提供商 *</label>
<select id="addModelProvider" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">模型名称 *</label>
<input type="text" id="addModelName" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="例如: gpt-4o, deepseek-ai/DeepSeek-V4">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">能力标签</label>
<div id="addModelCaps" class="flex flex-wrap gap-2"></div>
<p class="text-xs text-gray-500 mt-1">不勾选则默认继承提供商的全部能力</p>
</div>
</div>
<div class="p-6 border-t bg-gray-50 flex justify-end gap-3">
<button onclick="closeAddModelModal()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100">取消</button>
<button onclick="saveAddModel()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">保存</button>
</div>
</div>
</div>
<!-- 编辑模型弹窗 -->
<div id="editModelModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
<div class="p-6 border-b flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-800">编辑模型</h2>
<button onclick="closeEditModelModal()" class="text-gray-400 hover:text-gray-600">
<i class="ri-close-line text-2xl"></i>
</button>
</div>
<div class="p-6 space-y-4 overflow-auto flex-1">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">模型名称</label>
<input type="text" id="editModelName" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">能力标签(点击切换)</label>
<div id="editModelCaps" class="flex flex-wrap gap-2"></div>
</div>
<div class="text-xs text-gray-500">
所属提供商: <span id="editModelProviderName"></span>
</div>
</div>
<div class="p-6 border-t bg-gray-50 flex justify-end gap-3">
<button onclick="closeEditModelModal()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100">取消</button>
<button onclick="saveEditModel()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">保存</button>
</div>
</div>
</div>
<!-- 添加别名弹窗 -->
<div id="addAliasModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl w-full max-w-md">
<div class="p-6 border-b flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-800">添加模型别名</h2>
<button onclick="closeAddAliasModal()" class="text-gray-400 hover:text-gray-600">
<i class="ri-close-line text-2xl"></i>
</button>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">别名</label>
<input type="text" id="aliasKey" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="例如: qwen, deepseek, glm">
<p class="text-xs text-gray-500 mt-1">调用时使用 model="此别名"auto- 前缀保留给Auto配置)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">目标模型</label>
<input type="text" id="aliasTarget" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="例如: unsloth/Qwen3.8-27B-Q6_K">
<p class="text-xs text-gray-500 mt-1">可为具体模型名,也可为 auto 或 auto-xxx 配置</p>
</div>
</div>
<div class="p-6 border-t bg-gray-50 flex justify-end gap-3">
<button onclick="closeAddAliasModal()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100">取消</button>
<button onclick="saveAlias()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">保存</button>
</div>
</div>
</div>
<script>
let capabilityDefs = {};
let allModels = [];
let allProviders = [];
let editingModel = null;
// 初始化
async function init() {
const [capsRes, modelsRes, providersRes] = await Promise.all([
fetch('/api/admin/capabilities'),
fetch('/api/admin/models'),
fetch('/api/admin/providers')
]);
capabilityDefs = await capsRes.json();
allModels = await modelsRes.json();
allProviders = await providersRes.json();
renderLegend();
renderProviderFilter();
renderModels();
loadAliases();
}
function renderLegend() {
document.getElementById('capabilityLegend').innerHTML = `
<span class="text-sm text-gray-500 mr-1">能力说明:</span>
${Object.entries(capabilityDefs).map(([key, label]) => `
<span class="px-2 py-1 bg-indigo-50 text-indigo-700 rounded text-xs font-medium">
${label} <code class="text-indigo-400">${key}</code>
</span>
`).join('')}
`;
}
function renderProviderFilter() {
const sel = document.getElementById('providerFilter');
sel.innerHTML = '<option value="">全部提供商</option>' +
allProviders.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
}
function renderModels() {
const filter = document.getElementById('providerFilter').value;
const tbody = document.getElementById('modelTable');
const models = filter ? allModels.filter(m => m.provider_id === filter) : allModels;
if (models.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">暂无模型</td></tr>`;
return;
}
tbody.innerHTML = models.map(m => `
<tr class="border-b border-gray-50 hover:bg-gray-50">
<td class="px-6 py-4">
<code class="px-2 py-1 bg-purple-100 text-purple-700 rounded text-sm">${m.name}</code>
</td>
<td class="px-6 py-4">
<span class="text-gray-600 text-sm">${m.provider_name}</span>
${m.provider_enabled ? '' : '<span class="ml-1 text-xs text-red-500">(停用)</span>'}
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
${Object.keys(capabilityDefs).map(cap => {
const on = m.capabilities.includes(cap);
return `<span class="cap-chip px-2 py-0.5 rounded text-xs ${on ? 'on' : 'off'}"
onclick="toggleCap('${m.provider_id}', '${escapeStr(m.name)}', '${cap}', this)">${capabilityDefs[cap]}</span>`;
}).join('')}
</div>
</td>
<td class="px-6 py-4">
${m.is_default
? '<span class="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">✓ 默认</span>'
: `<button onclick="setDefault('${m.provider_id}', '${escapeStr(m.name)}')" class="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs hover:bg-gray-200">设为默认</button>`}
</td>
<td class="px-6 py-4">
<div class="flex gap-2">
<button onclick="openEditModel('${m.provider_id}', '${escapeStr(m.name)}')" class="px-2 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600">编辑</button>
<button onclick="deleteModel('${m.provider_id}', '${escapeStr(m.name)}')" class="px-2 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600">删除</button>
</div>
</td>
</tr>
`).join('');
// 渲染别名
document.querySelectorAll('tr[data-aliases]').forEach(() => {});
}
function escapeStr(s) {
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
// 点击切换能力
async function toggleCap(providerId, modelName, cap, el) {
const model = allModels.find(m => m.provider_id === providerId && m.name === modelName);
if (!model) return;
if (model.capabilities.includes(cap)) {
model.capabilities = model.capabilities.filter(c => c !== cap);
} else {
model.capabilities.push(cap);
}
const res = await fetch(`/api/admin/models/${providerId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName, capabilities: model.capabilities })
});
const data = await res.json();
if (data.success) {
el.classList.toggle('on', model.capabilities.includes(cap));
el.classList.toggle('off', !model.capabilities.includes(cap));
} else {
alert('更新失败: ' + (data.error || ''));
init();
}
}
async function setDefault(providerId, modelName) {
const res = await fetch(`/api/admin/models/${providerId}/default`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: modelName })
});
const data = await res.json();
if (data.success) {
init();
} else {
alert('设置失败: ' + (data.error || ''));
}
}
async function deleteModel(providerId, modelName) {
if (!confirm(`确定删除模型 "${modelName}" 吗?`)) return;
const res = await fetch(`/api/admin/models/${providerId}?name=${encodeURIComponent(modelName)}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
init();
} else {
alert('删除失败: ' + (data.error || ''));
}
}
// 添加模型
function showAddModelModal() {
const sel = document.getElementById('addModelProvider');
sel.innerHTML = allProviders.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
document.getElementById('addModelName').value = '';
renderCapChips('addModelCaps', []);
document.getElementById('addModelModal').classList.remove('hidden');
document.getElementById('addModelModal').classList.add('flex');
}
function closeAddModelModal() {
document.getElementById('addModelModal').classList.add('hidden');
document.getElementById('addModelModal').classList.remove('flex');
}
async function saveAddModel() {
const providerId = document.getElementById('addModelProvider').value;
const name = document.getElementById('addModelName').value.trim();
if (!name) { alert('请输入模型名称'); return; }
const caps = [];
document.querySelectorAll('#addModelCaps .cap-chip.on').forEach(c => caps.push(c.dataset.cap));
if (caps.length === 0) caps.push('text');
const res = await fetch('/api/admin/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: providerId, name, capabilities: caps })
});
const data = await res.json();
if (data.success) {
closeAddModelModal();
init();
alert('✅ 模型已添加');
} else {
alert('添加失败: ' + (data.error || ''));
}
}
// 编辑模型
function openEditModel(providerId, modelName) {
const model = allModels.find(m => m.provider_id === providerId && m.name === modelName);
if (!model) return;
editingModel = model;
document.getElementById('editModelName').value = model.name;
document.getElementById('editModelProviderName').textContent = model.provider_name;
renderCapChips('editModelCaps', model.capabilities);
document.getElementById('editModelModal').classList.remove('hidden');
document.getElementById('editModelModal').classList.add('flex');
}
function closeEditModelModal() {
document.getElementById('editModelModal').classList.add('hidden');
document.getElementById('editModelModal').classList.remove('flex');
editingModel = null;
}
async function saveEditModel() {
if (!editingModel) return;
const newName = document.getElementById('editModelName').value.trim();
if (!newName) { alert('模型名称不能为空'); return; }
const caps = [];
document.querySelectorAll('#editModelCaps .cap-chip.on').forEach(c => caps.push(c.dataset.cap));
if (caps.length === 0) caps.push('text');
const body = { old_name: editingModel.name, capabilities: caps };
if (newName !== editingModel.name) body.new_name = newName;
const res = await fetch(`/api/admin/models/${editingModel.provider_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.success) {
closeEditModelModal();
init();
alert('✅ 模型已更新');
} else {
alert('更新失败: ' + (data.error || ''));
}
}
// 能力chips渲染(通用)
function renderCapChips(containerId, selectedCaps) {
const container = document.getElementById(containerId);
container.innerHTML = Object.entries(capabilityDefs).map(([key, label]) => `
<span class="cap-chip px-3 py-1 rounded-lg text-sm ${selectedCaps.includes(key) ? 'on' : 'off'}"
data-cap="${key}" onclick="this.classList.toggle('on'); this.classList.toggle('off')">${label}</span>
`).join('');
}
// 别名管理
async function loadAliases() {
const res = await fetch('/api/admin/aliases');
const aliases = await res.json();
const container = document.getElementById('aliasList');
const entries = Object.entries(aliases);
if (entries.length === 0) {
container.innerHTML = '<p class="text-gray-500 col-span-full text-sm">暂无别名</p>';
return;
}
container.innerHTML = entries.map(([alias, target]) => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg group">
<div class="min-w-0">
<code class="text-purple-600 text-sm">${alias}</code>
<span class="text-gray-400 mx-1">→</span>
<code class="text-gray-700 text-xs">${target}</code>
</div>
${alias === 'auto' ? '' : `
<button onclick="deleteAlias('${alias}')" class="p-1 text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition">
<i class="ri-delete-bin-line"></i>
</button>`}
</div>
`).join('');
}
function showAddAliasModal() {
document.getElementById('aliasKey').value = '';
document.getElementById('aliasTarget').value = '';
document.getElementById('addAliasModal').classList.remove('hidden');
document.getElementById('addAliasModal').classList.add('flex');
}
function closeAddAliasModal() {
document.getElementById('addAliasModal').classList.add('hidden');
document.getElementById('addAliasModal').classList.remove('flex');
}
async function saveAlias() {
const alias = document.getElementById('aliasKey').value.trim();
const target = document.getElementById('aliasTarget').value.trim();
if (!alias || !target) { alert('请填写别名和目标模型'); return; }
const res = await fetch('/api/admin/aliases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias, target })
});
const data = await res.json();
if (data.success) {
closeAddAliasModal();
loadAliases();
} else {
alert('保存失败: ' + (data.error || ''));
}
}
async function deleteAlias(alias) {
if (!confirm(`确定删除别名 "${alias}" 吗?`)) return;
const res = await fetch(`/api/admin/aliases/${alias}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
loadAliases();
} else {
alert('删除失败: ' + (data.error || ''));
}
}
init();
</script>
</body>
</html>