Files
llm-proxy/templates/models.html
T
hz4th_coder 178968ba1a v2.1.0: 能力路由体系 + 模型管理 + 生图/语音/视频端点 + 大量修复
- 提供商重构: Local Qwen(文本+视觉) / SiliconFlow LLM(文本) / Autodl(文本+视觉) / Autodl Image(生图)
- 能力标签: text/vision/audio_out/audio_in/image_gen/video_gen,模型级可配
- AUTO配置绑定能力: auto/auto-text/auto-vision/auto-image/auto-voice-out/auto-voice-in/auto-video
- 新端点: /v1/images/generations /v1/audio/speech /v1/audio/transcriptions /v1/video/generations
- 模型管理页: 能力切换/增删改/设默认/别名管理
- 修复: 失败切换不再替换用户模型; 熔断冷却后自动恢复; 重试不再白等; embeddings按模型路由
- 修复: 后台模板API路径(/api/admin); 删除v1旧admin目录与双端口混乱
- 修复: run.sh硬编码路径; 死配置RETRY/LOG接线; engine_completions空body防护
- 端口: 16003(16001-16100白名单区间); debug关闭; start.sh部署脚本
2026-08-28 11:41:17 +08:00

505 lines
25 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>