Files
llm-proxy/templates/config.html
T
hz4th_coder 86198e83c5 v2.2.0: API Key鉴权(可配置) + Function Calling透传 + MCP Server标准接口
- API Key鉴权: /v1/* 与 /mcp 接口支持 Bearer 鉴权(OpenAI兼容), 系统配置页可启停/增删Key
  (GET/PUT /api/admin/apikeys, key脱敏显示; /health /status 不受限; 内部自调用自动带key)
- Function Calling: tools/tool_calls 全透传上游, 实测 GLM-5.3-flash 返回 tool_calls(get_weather)
- MCP Server: /mcp 端点(Streamable HTTP + SSE兼容), 暴露 text_complete/vision_complete/image_generate 工具
  (initialize/ping/tools/list/tools/call/prompts.list/resources.list; 实测生图返回图片URL)
- 默认生成 API Key: sk-hz4th-<hex>
2026-08-28 15:24:18 +08:00

463 lines
27 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%); }
</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 text-slate-300 hover:bg-slate-700 hover: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 bg-slate-700 text-white">
<i class="ri-settings-3-line"></i><span>系统配置</span>
</a>
</nav>
</aside>
<main class="ml-64 flex-1 p-8">
<h1 class="text-2xl font-bold text-gray-800 mb-6">系统配置</h1>
<div id="configContent" class="space-y-6">
<p class="text-gray-500">加载中...</p>
</div>
</main>
</div>
<script>
async function loadConfig() {
const res = await fetch('/api/admin/config');
const data = await res.json();
const container = document.getElementById('configContent');
container.innerHTML = `
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">服务配置</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-500">监听端口</p>
<p class="font-medium">${data.server_config.port}</p>
</div>
<div>
<p class="text-sm text-gray-500">重试次数</p>
<p class="font-medium">${data.retry_config.max_retries}</p>
</div>
<div>
<p class="text-sm text-gray-500">熔断冷却</p>
<p class="font-medium">${data.retry_config.cooldown_seconds}s</p>
</div>
<div>
<p class="text-sm text-gray-500">能力类型</p>
<p class="font-medium">${Object.keys(data.capabilities).length} 种</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">路由缓存(历史上下文优先复用上次模型)</h2>
<div class="space-y-4">
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<p class="font-medium text-gray-800">优先复用上次模型</p>
<p class="text-sm text-gray-500 mt-1">带历史上下文的 auto 请求,优先使用上一次成功响应的模型,命中上游前缀缓存、节省成本</p>
</div>
<button id="cacheToggle" onclick="toggleCacheModel()" class="relative w-12 h-6 rounded-full transition ${data.routing_config.prefer_cache_model ? 'bg-green-500' : 'bg-gray-300'}">
<span id="cacheKnob" class="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition ${data.routing_config.prefer_cache_model ? 'left-6' : 'left-0.5'}"></span>
</button>
</div>
<div class="flex items-end gap-3 p-4 bg-gray-50 rounded-lg">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">记忆有效期(秒)</label>
<input type="number" id="cacheTtl" value="${data.routing_config.cache_ttl_seconds}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<p class="text-xs text-gray-500 mt-1">超过该时长后不再强制复用,重新按顺序选择</p>
</div>
<button onclick="saveRouting()" 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 class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-1">邮件通知(auto 调用全部失败时通知)</h2>
<p class="text-sm text-gray-500 mb-4">某个 auto 配置被调用后所有提供商都失败,自动发送邮件到收件人</p>
<div id="emailConfigContent" class="space-y-4">
<p class="text-gray-500 text-sm">加载中...</p>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-1">接口认证(API Key</h2>
<p class="text-sm text-gray-500 mb-4">为 /v1/* 与 /mcp 接口启用鉴权,调用时携带 <code>Authorization: Bearer &lt;key&gt;</code>OpenAI 兼容)</p>
<div id="apiKeyConfigContent" class="space-y-4">
<p class="text-gray-500 text-sm">加载中...</p>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">标准接口</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="p-4 bg-gray-50 rounded-lg">
<p class="font-medium text-gray-800">OpenAI 兼容</p>
<p class="text-sm text-gray-500 mt-1">/v1/chat/completions 等,支持 <code>tools</code> / <code>function calling</code> / 流式</p>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<p class="font-medium text-gray-800">MCP Server</p>
<p class="text-sm text-gray-500 mt-1">连接地址: <code>http://localhost:${data.server_config.port}/mcp</code></p>
<p class="text-xs text-gray-400 mt-1">工具: text_complete / vision_complete / image_generate</p>
</div>
<div class="p-4 bg-gray-50 rounded-lg">
<p class="font-medium text-gray-800">Function Calling</p>
<p class="text-sm text-gray-500 mt-1">请求体携带 <code>tools</code> 数组即可透传到上游模型,返回 <code>tool_calls</code></p>
</div>
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
<div class="space-y-4">
${data.providers.map(p => `
<div class="p-4 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">${p.name}</span>
<span class="text-sm ${p.enabled ? 'text-green-600' : 'text-gray-400'}">${p.enabled ? '已启用' : '已禁用'}</span>
</div>
<div class="text-sm text-gray-500 space-y-1">
<p>API: ${p.base_url}</p>
<p>能力: ${(p.capabilities || []).join(', ')}</p>
<p>模型: ${p.models.map(m => m.name).join(', ')}</p>
<p>默认模型: ${p.default_model}</p>
<p>超时: ${p.timeout}s</p>
</div>
</div>
`).join('')}
</div>
</div>
<div class="bg-white rounded-xl border border-gray-100 p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">模型别名</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2">
${Object.entries(data.model_aliases).map(([alias, target]) => `
<div class="p-3 bg-gray-50 rounded-lg">
<code class="text-purple-600">${alias}</code>
<span class="text-gray-400 mx-2">→</span>
<code class="text-gray-700">${target}</code>
</div>
`).join('')}
</div>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<p class="text-yellow-700 text-sm">
<i class="ri-information-line mr-1"></i>
配置修改请在 <a href="/admin/providers" class="underline">提供商管理</a> / <a href="/admin/models" class="underline">模型管理</a> / <a href="/admin/auto-profiles" class="underline">Auto配置</a> 中操作;也可编辑 <code>config/settings.py</code> 的默认值后重启
</p>
</div>
`;
loadEmailConfig();
loadApiKeys();
}
async function toggleCacheModel() {
const data = await (await fetch('/api/admin/routing')).json();
const newVal = !data.prefer_cache_model;
const res = await fetch('/api/admin/routing', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefer_cache_model: newVal })
});
const result = await res.json();
if (result.success) {
const btn = document.getElementById('cacheToggle');
const knob = document.getElementById('cacheKnob');
if (newVal) {
btn.classList.remove('bg-gray-300'); btn.classList.add('bg-green-500');
knob.classList.remove('left-0.5'); knob.classList.add('left-6');
} else {
btn.classList.remove('bg-green-500'); btn.classList.add('bg-gray-300');
knob.classList.remove('left-6'); knob.classList.add('left-0.5');
}
} else {
alert('保存失败: ' + (result.error || ''));
}
}
async function saveRouting() {
const ttl = parseInt(document.getElementById('cacheTtl').value) || 3600;
const res = await fetch('/api/admin/routing', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cache_ttl_seconds: ttl })
});
const result = await res.json();
if (result.success) {
alert('✅ 已保存');
} else {
alert('保存失败: ' + (result.error || ''));
}
}
async function loadEmailConfig() {
const res = await fetch('/api/admin/email');
const e = await res.json();
const container = document.getElementById('emailConfigContent');
container.innerHTML = `
<div class="grid grid-cols-2 gap-4">
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<span class="text-sm text-gray-700">启用邮件通知</span>
<button id="emailEnabledToggle" onclick="toggleEmailEnabled()" class="relative w-10 h-5 rounded-full transition ${e.enabled ? 'bg-green-500' : 'bg-gray-300'}">
<span id="emailEnabledKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.enabled ? 'left-5' : 'left-0.5'}"></span>
</button>
</div>
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<span class="text-sm text-gray-700">auto 失败时通知</span>
<button id="notifyToggle" onclick="toggleNotify()" class="relative w-10 h-5 rounded-full transition ${e.notify_on_auto_failure ? 'bg-green-500' : 'bg-gray-300'}">
<span id="notifyKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.notify_on_auto_failure ? 'left-5' : 'left-0.5'}"></span>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">SMTP 服务器</label>
<input type="text" id="emailHost" value="${e.smtp_host}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">端口</label>
<input type="number" id="emailPort" value="${e.smtp_port}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">加密方式</label>
<select id="emailMode" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="plain" ${e.smtp_mode==='plain'?'selected':''}>plain(无加密)</option>
<option value="starttls" ${e.smtp_mode==='starttls'?'selected':''}>STARTTLS</option>
<option value="ssl" ${e.smtp_mode==='ssl'?'selected':''}>SSL</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<input type="text" id="emailUser" value="${e.smtp_user}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码(留空不改)</label>
<input type="password" id="emailPass" value="${e.smtp_password}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发件人名称</label>
<input type="text" id="emailFromName" value="${e.from_name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发件地址</label>
<input type="text" id="emailFromAddr" value="${e.from_addr}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">收件人(逗号分隔)</label>
<input type="text" id="emailTo" value="${(e.to_addrs||[]).join(', ')}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-1">失败通知最小间隔(秒)</label>
<input type="number" id="emailCooldown" value="${e.cooldown_seconds}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
</div>
<button onclick="saveEmail()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
<i class="ri-save-line mr-1"></i> 保存
</button>
<button onclick="testEmail(this)" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
<i class="ri-mail-send-line mr-1"></i> 发送测试邮件
</button>
</div>
`;
}
async function toggleEmailEnabled() {
const res = await fetch('/api/admin/email');
const e = await res.json();
const newVal = !e.enabled;
const r = await fetch('/api/admin/email', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newVal })
});
const d = await r.json();
if (d.success) {
const btn = document.getElementById('emailEnabledToggle');
const knob = document.getElementById('emailEnabledKnob');
btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal);
knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal);
} else { alert('保存失败: ' + (d.error || '')); }
}
async function toggleNotify() {
const res = await fetch('/api/admin/email');
const e = await res.json();
const newVal = !e.notify_on_auto_failure;
const r = await fetch('/api/admin/email', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notify_on_auto_failure: newVal })
});
const d = await r.json();
if (d.success) {
const btn = document.getElementById('notifyToggle');
const knob = document.getElementById('notifyKnob');
btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal);
knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal);
} else { alert('保存失败: ' + (d.error || '')); }
}
async function saveEmail() {
const data = {
smtp_host: document.getElementById('emailHost').value.trim(),
smtp_port: parseInt(document.getElementById('emailPort').value) || 25,
smtp_mode: document.getElementById('emailMode').value,
smtp_user: document.getElementById('emailUser').value.trim(),
from_name: document.getElementById('emailFromName').value.trim(),
from_addr: document.getElementById('emailFromAddr').value.trim(),
to_addrs: document.getElementById('emailTo').value,
cooldown_seconds: parseInt(document.getElementById('emailCooldown').value) || 300
};
const pass = document.getElementById('emailPass').value;
if (pass && pass !== '********') data.smtp_password = pass;
const res = await fetch('/api/admin/email', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const d = await res.json();
if (d.success) {
alert('✅ 邮件配置已保存');
loadEmailConfig();
} else { alert('保存失败: ' + (d.error || '')); }
}
async function testEmail(btn) {
if (!btn) 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/admin/email/test', { method: 'POST' });
const d = await res.json();
if (d.success) { alert('✅ ' + d.message); }
else { alert('❌ ' + (d.error || '发送失败')); }
} catch (e) {
alert('❌ ' + e.message);
}
btn.disabled = false; btn.innerHTML = '<i class="ri-mail-send-line mr-1"></i> 发送测试邮件';
}
async function loadApiKeys() {
const res = await fetch('/api/admin/apikeys');
const d = await res.json();
const container = document.getElementById('apiKeyConfigContent');
container.innerHTML = `
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<span class="text-sm text-gray-700">启用接口鉴权</span>
<button id="apiAuthToggle" onclick="toggleApiAuth()" class="relative w-10 h-5 rounded-full transition ${d.enabled ? 'bg-green-500' : 'bg-gray-300'}">
<span id="apiAuthKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${d.enabled ? 'left-5' : 'left-0.5'}"></span>
</button>
<span class="text-xs text-gray-400">关闭后所有请求免鉴权,谨慎操作</span>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">已配置 Key${d.keys.length} 个)</label>
<div id="apiKeyList" class="space-y-2">
${d.keys.map(k => `
<div class="flex items-center gap-2 p-2 bg-gray-50 rounded-lg">
<code class="flex-1 text-sm text-gray-800 truncate">${k}</code>
<button onclick="copyKey('${k}')" class="px-2 py-1 bg-indigo-100 text-indigo-600 rounded text-xs hover:bg-indigo-200">复制</button>
<button onclick="removeApiKey('${k}')" class="px-2 py-1 bg-red-100 text-red-600 rounded text-xs hover:bg-red-200">删除</button>
</div>
`).join('')}
</div>
</div>
<div class="flex gap-2">
<input type="text" id="newApiKey" placeholder="输入新的 API Key,如 sk-xxxx"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg">
<button onclick="addApiKey()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
<i class="ri-add-line mr-1"></i> 添加 Key
</button>
</div>
<div class="p-3 bg-blue-50 rounded-lg text-xs text-blue-600">
<i class="ri-information-line mr-1"></i>
使用示例:<code>curl http://<IP>:16003/v1/chat/completions -H "Authorization: Bearer &lt;key&gt;" ...</code>
</div>
`;
}
async function toggleApiAuth() {
const d = await (await fetch('/api/admin/apikeys')).json();
const newVal = !d.enabled;
const r = await fetch('/api/admin/apikeys', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newVal })
});
const res = await r.json();
if (res.success) {
const btn = document.getElementById('apiAuthToggle');
const knob = document.getElementById('apiAuthKnob');
btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal);
knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal);
} else { alert('操作失败: ' + (res.error || '')); }
}
async function addApiKey() {
const k = document.getElementById('newApiKey').value.trim();
if (!k) { alert('请输入 Key'); return; }
const r = await fetch('/api/admin/apikeys', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ add_key: k })
});
const res = await r.json();
if (res.success) { loadApiKeys(); } else { alert('添加失败: ' + (res.error || '')); }
}
async function removeApiKey(k) {
if (!confirm('确定删除这个 Key')) return;
const r = await fetch('/api/admin/apikeys', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ remove_key: k })
});
const res = await r.json();
if (res.success) { loadApiKeys(); } else { alert('删除失败: ' + (res.error || '')); }
}
function copyKey(k) {
navigator.clipboard.writeText(k).then(() => alert('已复制: ' + k));
}
loadConfig();
</script>
</body>
</html>