Files
llm-proxy/admin/templates/providers.html
hubian 3f463f2f98 feat: 添加自定义Auto配置功能
新增功能:
- Auto配置管理页面 (/auto-profiles)
- 创建自定义auto模式,如auto-fast, auto-cheap
- 指定候选提供商和优先级
- 支持按优先级/随机选择策略

API:
- GET/POST /api/auto-profiles
- GET/PUT/DELETE /api/auto-profiles/<name>

改进:
- 主服务支持自定义auto模式路由
- 模型列表显示所有auto配置
2026-04-09 17:46:47 +08:00

606 lines
29 KiB
HTML
Raw 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%); }
.dragging { opacity: 0.5; transform: scale(1.02); }
.drag-over { border: 2px dashed #6366f1; }
</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="/" 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="/providers" class="flex items-center gap-3 px-6 py-3 bg-slate-700 text-white">
<i class="ri-server-line"></i><span>提供商管理</span>
</a>
<a href="/models" class="flex items-center gap-3 px-6 py-3 text-slate-300 hover:bg-slate-700 hover:text-white">
<i class="ri-cpu-line"></i><span>模型管理</span>
</a>
<a href="/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="/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="/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>
<button onclick="showAddModal()" 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 id="providerList" class="space-y-4">
<p class="text-gray-500">加载中...</p>
</div>
<!-- 优先级排序提示 -->
<div class="mt-6 p-4 bg-blue-50 rounded-lg text-sm text-blue-600">
<i class="ri-information-line mr-1"></i>
<strong>Auto模式优先级</strong> 当使用 model="auto" 时,系统会按优先级顺序依次尝试可用的提供商。拖拽上方卡片可调整顺序。
</div>
</main>
</div>
<!-- 添加/编辑提供商弹窗 -->
<div id="editModal" 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-xl 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" id="modalTitle">添加提供商</h2>
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
<i class="ri-close-line text-2xl"></i>
</button>
</div>
<form id="providerForm" class="p-6 overflow-auto flex-1 space-y-4">
<input type="hidden" id="providerId" name="id">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">提供商名称 *</label>
<input type="text" id="providerName" name="name" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="例如: OpenAI GPT-4">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API地址 *</label>
<input type="text" id="providerBaseUrl" name="base_url" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="例如: https://api.openai.com/v1">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Key *</label>
<input type="text" id="providerApiKey" name="api_key" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="例如: sk-xxx...">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">支持模型(逗号分隔)*</label>
<input type="text" id="providerModels" name="models" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="例如: gpt-4, gpt-3.5-turbo">
<p class="text-xs text-gray-500 mt-1">多个模型用逗号分隔</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">默认模型</label>
<input type="text" id="providerDefaultModel" name="default_model"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="留空则使用第一个模型">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">超时时间(秒)</label>
<input type="number" id="providerTimeout" name="timeout" value="120"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="providerEnabled" name="enabled" checked
class="w-4 h-4 text-purple-600 rounded focus:ring-purple-500">
<label class="text-sm text-gray-700">启用此提供商</label>
</div>
</form>
<div class="p-6 border-t bg-gray-50 flex justify-end gap-3">
<button onclick="closeModal()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-100">
取消
</button>
<button onclick="testConnection()" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
<i class="ri-refresh-line mr-1"></i> 测试连接
</button>
<button onclick="saveProvider()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
<i class="ri-save-line mr-1"></i> 保存
</button>
</div>
</div>
</div>
<!-- 详情弹窗 -->
<div id="detailModal" 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-2xl max-h-[80vh] 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" id="detailModalTitle">提供商详情</h2>
<button onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-600">
<i class="ri-close-line text-2xl"></i>
</button>
</div>
<div class="p-6 overflow-auto flex-1" id="detailModalContent"></div>
</div>
</div>
<script>
let providers = [];
let draggedItem = null;
// 加载提供商列表
async function loadProviders() {
const res = await fetch('/api/providers');
providers = await res.json();
const container = document.getElementById('providerList');
if (providers.length === 0) {
container.innerHTML = `
<div class="text-center py-12">
<i class="ri-server-line text-6xl text-gray-300 mb-4"></i>
<p class="text-gray-500 mb-4">暂无提供商,点击上方按钮添加</p>
</div>
`;
return;
}
container.innerHTML = providers.map(p => `
<div class="provider-card bg-white rounded-xl border border-gray-100 overflow-hidden cursor-move"
data-id="${p.id}" draggable="true"
ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"
ondragover="handleDragOver(event)" ondrop="handleDrop(event)">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="w-10 h-10 ${p.available ? 'bg-green-500' : 'bg-red-500'} text-white rounded-lg flex items-center justify-center text-lg font-bold cursor-grab">
${p.priority}
</span>
<div>
<h3 class="font-semibold text-gray-800 text-lg">${p.name}</h3>
<p class="text-sm text-gray-500">优先级 ${p.priority} · ${p.enabled ? '已启用' : '已禁用'}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="px-3 py-1 rounded-full text-sm ${p.available ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'}">
${p.available ? '● 可用' : '○ 不可用'}
</span>
<button onclick="toggleProvider('${p.id}')"
class="px-3 py-1 rounded-full text-sm ${p.enabled ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'} hover:opacity-80">
${p.enabled ? 'ON' : 'OFF'}
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4 text-sm">
<div>
<p class="text-gray-500">API地址</p>
<p class="text-gray-800 truncate">${p.base_url}</p>
</div>
<div>
<p class="text-gray-500">超时时间</p>
<p class="text-gray-800">${p.timeout}s</p>
</div>
<div>
<p class="text-gray-500">支持模型</p>
<div class="flex flex-wrap gap-1 mt-1">
${p.models.map(m => `<span class="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">${m}</span>`).join('')}
</div>
</div>
<div>
<p class="text-gray-500">请求统计</p>
<p class="text-gray-800">${p.request_count} 请求 · ${p.success_count} 成功</p>
</div>
</div>
<div class="flex gap-2">
<button onclick="testProvider('${p.id}')" class="px-4 py-2 bg-green-500 text-white rounded-lg text-sm hover:bg-green-600">
<i class="ri-refresh-line mr-1"></i> 测试
</button>
<button onclick="editProvider('${p.id}')" class="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600">
<i class="ri-edit-line mr-1"></i> 编辑
</button>
<button onclick="viewDetail('${p.id}')" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50">
<i class="ri-eye-line mr-1"></i> 详情
</button>
<button onclick="deleteProvider('${p.id}')" class="px-4 py-2 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600">
<i class="ri-delete-bin-line mr-1"></i> 删除
</button>
</div>
${p.last_error ? `
<div class="mt-4 p-3 bg-red-50 rounded-lg text-sm text-red-600">
<i class="ri-error-warning-line mr-1"></i> 最后错误: ${p.last_error}
</div>
` : ''}
</div>
</div>
`).join('');
}
// 拖拽相关
function handleDragStart(e) {
draggedItem = e.target.closest('.provider-card');
draggedItem.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
}
function handleDragEnd(e) {
e.target.closest('.provider-card').classList.remove('dragging');
document.querySelectorAll('.provider-card').forEach(card => {
card.classList.remove('drag-over');
});
}
function handleDragOver(e) {
e.preventDefault();
const card = e.target.closest('.provider-card');
if (card && card !== draggedItem) {
card.classList.add('drag-over');
}
}
function handleDrop(e) {
e.preventDefault();
const targetCard = e.target.closest('.provider-card');
if (!targetCard || targetCard === draggedItem) return;
targetCard.classList.remove('drag-over');
// 重新排序
const container = document.getElementById('providerList');
const cards = [...container.querySelectorAll('.provider-card')];
const draggedIndex = cards.indexOf(draggedItem);
const targetIndex = cards.indexOf(targetCard);
if (draggedIndex < targetIndex) {
targetCard.after(draggedItem);
} else {
targetCard.before(draggedItem);
}
// 更新优先级
updatePriority();
}
async function updatePriority() {
const cards = document.querySelectorAll('.provider-card');
const order = [...cards].map(card => card.dataset.id);
try {
const res = await fetch('/api/providers/priority', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order })
});
const data = await res.json();
if (data.success) {
// 重新加载以更新显示的优先级数字
loadProviders();
}
} catch (e) {
console.error('更新优先级失败:', e);
}
}
// 弹窗操作
function showAddModal() {
document.getElementById('modalTitle').textContent = '添加提供商';
document.getElementById('providerForm').reset();
document.getElementById('providerId').value = '';
document.getElementById('providerEnabled').checked = true;
document.getElementById('providerTimeout').value = 120;
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('editModal').classList.add('flex');
}
async function editProvider(id) {
const provider = providers.find(p => p.id === id);
if (!provider) return;
document.getElementById('modalTitle').textContent = '编辑提供商';
document.getElementById('providerId').value = provider.id;
document.getElementById('providerName').value = provider.name;
document.getElementById('providerBaseUrl').value = provider.base_url;
document.getElementById('providerApiKey').value = provider.api_key;
document.getElementById('providerModels').value = provider.models.join(', ');
document.getElementById('providerDefaultModel').value = provider.default_model || '';
document.getElementById('providerTimeout').value = provider.timeout || 120;
document.getElementById('providerEnabled').checked = provider.enabled;
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('editModal').classList.add('flex');
}
function closeModal() {
document.getElementById('editModal').classList.add('hidden');
document.getElementById('editModal').classList.remove('flex');
}
async function saveProvider() {
const id = document.getElementById('providerId').value;
const name = document.getElementById('providerName').value.trim();
const base_url = document.getElementById('providerBaseUrl').value.trim();
const api_key = document.getElementById('providerApiKey').value.trim();
const modelsStr = document.getElementById('providerModels').value.trim();
const default_model = document.getElementById('providerDefaultModel').value.trim();
const timeout = parseInt(document.getElementById('providerTimeout').value) || 120;
const enabled = document.getElementById('providerEnabled').checked;
// 验证
if (!name || !base_url || !api_key || !modelsStr) {
alert('请填写所有必填字段!');
return;
}
const models = modelsStr.split(',').map(m => m.trim()).filter(m => m);
const data = {
name,
base_url,
api_key,
models,
default_model: default_model || models[0],
timeout,
enabled
};
try {
let res;
if (id) {
// 编辑
res = await fetch(`/api/providers/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
// 新增
res = await fetch('/api/providers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await res.json();
if (result.success) {
closeModal();
loadProviders();
alert(id ? '✅ 提供商已更新!' : '✅ 提供商已添加!');
} else {
alert('❌ 操作失败: ' + result.error);
}
} catch (e) {
alert('保存失败: ' + e.message);
}
}
async function testConnection() {
const base_url = document.getElementById('providerBaseUrl').value.trim();
const api_key = document.getElementById('providerApiKey').value.trim();
if (!base_url || !api_key) {
alert('请先填写 API 地址和 Key');
return;
}
try {
const url = base_url.replace(/\/$/, '') + '/models';
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${api_key}` }
});
if (res.ok) {
const data = await res.json();
const models = data.data || [];
alert(`✅ 连接成功!找到 ${models.length} 个模型`);
// 如果模型列表为空,提示可以填入
if (models.length > 0 && !document.getElementById('providerModels').value.trim()) {
const modelIds = models.map(m => m.id).slice(0, 5).join(', ');
document.getElementById('providerModels').value = modelIds;
}
} else {
alert(`❌ 连接失败: HTTP ${res.status}`);
}
} catch (e) {
alert('❌ 连接失败: ' + e.message);
}
}
async function testProvider(id) {
const provider = providers.find(p => p.id === id);
if (!provider) return;
const btn = event.target.closest('button');
btn.disabled = true;
btn.innerHTML = '<i class="ri-loader-4-line animate-spin mr-1"></i> 测试中...';
try {
const res = await fetch(`/api/providers/${id}/test`, { method: 'POST' });
const data = await res.json();
if (data.success) {
alert(`✅ 连接成功!找到 ${data.models_count || '?'} 个模型`);
} else {
alert('❌ 连接失败: ' + data.error);
}
} catch (e) {
alert('测试失败: ' + e.message);
}
btn.disabled = false;
btn.innerHTML = '<i class="ri-refresh-line mr-1"></i> 测试';
loadProviders();
}
async function toggleProvider(id) {
try {
const res = await fetch(`/api/providers/${id}/toggle`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadProviders();
}
} catch (e) {
alert('切换失败: ' + e.message);
}
}
async function deleteProvider(id) {
const provider = providers.find(p => p.id === id);
if (!provider) return;
if (!confirm(`确定删除提供商 "${provider.name}"?此操作不可恢复!`)) {
return;
}
try {
const res = await fetch(`/api/providers/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
loadProviders();
alert('✅ 提供商已删除');
} else {
alert('❌ 删除失败: ' + data.error);
}
} catch (e) {
alert('删除失败: ' + e.message);
}
}
async function viewDetail(id) {
const res = await fetch(`/api/providers/${id}`);
const data = await res.json();
document.getElementById('detailModalTitle').textContent = data.name;
document.getElementById('detailModalContent').innerHTML = `
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-500">ID</p>
<p class="font-medium">${data.id}</p>
</div>
<div>
<p class="text-sm text-gray-500">API地址</p>
<p class="font-medium">${data.base_url}</p>
</div>
<div>
<p class="text-sm text-gray-500">默认模型</p>
<p class="font-medium">${data.default_model}</p>
</div>
<div>
<p class="text-sm text-gray-500">优先级</p>
<p class="font-medium">${data.priority}</p>
</div>
<div>
<p class="text-sm text-gray-500">超时</p>
<p class="font-medium">${data.timeout}s</p>
</div>
<div>
<p class="text-sm text-gray-500">状态</p>
<p class="font-medium ${data.enabled ? 'text-green-600' : 'text-gray-500'}">
${data.enabled ? '已启用' : '已禁用'}
</p>
</div>
</div>
<div>
<p class="text-sm text-gray-500 mb-2">支持模型</p>
<div class="flex flex-wrap gap-2">
${data.models.map(m => `<span class="px-3 py-1 bg-gray-100 rounded-lg text-sm">${m}</span>`).join('')}
</div>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<h4 class="font-medium mb-2">运行状态</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500">可用状态</p>
<p class="${data.status.available ? 'text-green-600' : 'text-red-600'}">
${data.status.available ? '✓ 可用' : '✗ 不可用'}
</p>
</div>
<div>
<p class="text-gray-500">错误次数</p>
<p>${data.status.error_count}</p>
</div>
<div>
<p class="text-gray-500">请求次数</p>
<p>${data.status.request_count}</p>
</div>
<div>
<p class="text-gray-500">成功次数</p>
<p>${data.status.success_count}</p>
</div>
${data.status.last_error ? `
<div class="col-span-2">
<p class="text-gray-500">最后错误</p>
<p class="text-red-600">${data.status.last_error}</p>
</div>
` : ''}
</div>
</div>
<div class="flex gap-2 pt-4">
<button onclick="closeDetailModal(); editProvider('${data.id}')" class="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600">
<i class="ri-edit-line mr-1"></i> 编辑
</button>
<button onclick="testProvider('${data.id}'); closeDetailModal()" class="px-4 py-2 bg-green-500 text-white rounded-lg text-sm hover:bg-green-600">
<i class="ri-refresh-line mr-1"></i> 测试连接
</button>
</div>
</div>
`;
document.getElementById('detailModal').classList.remove('hidden');
document.getElementById('detailModal').classList.add('flex');
}
function closeDetailModal() {
document.getElementById('detailModal').classList.add('hidden');
document.getElementById('detailModal').classList.remove('flex');
}
// 初始化
loadProviders();
</script>
</body>
</html>