Files
ModelRouter/internal/gateway/ui/index.html
root f7f76e097d feat: ModelRouter — unified OpenAI-compatible multi-source LLM gateway
- Lua adapters per upstream (transform_request/response/stream_chunk, build_headers signing hooks)
- AUTO priority routing with per-model kind (chat/image), explicit source/model routing
- Per-source concurrency caps with queueing, exponential backoff, AUTO failover
- OpenAI-compatible API: chat completions, SSE streaming, image generations, models
- Gateway key auth, web UI for adapter/source management, runtime persistence
- e2e test running the real binary against mocked upstreams
2026-08-05 15:25:47 +08:00

241 lines
12 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">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>llmsproxy</title>
<style>
:root { --bg:#0f1115; --card:#171a21; --line:#262b36; --fg:#e6e8ee; --muted:#8b93a5;
--accent:#4f7cff; --ok:#3ecf8e; --warn:#ffb454; --err:#ff5d6c; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.5 ui-monospace,Menlo,Consolas,monospace; background:var(--bg); color:var(--fg); }
header { display:flex; align-items:center; gap:12px; padding:14px 20px; border-bottom:1px solid var(--line); }
header h1 { font-size:16px; margin:0; }
header .badge { font-size:12px; color:var(--muted); }
nav { display:flex; gap:4px; padding:10px 20px; border-bottom:1px solid var(--line); }
nav button { background:transparent; border:1px solid transparent; color:var(--muted); padding:6px 14px;
cursor:pointer; border-radius:6px; font:inherit; }
nav button.active { background:var(--card); border-color:var(--line); color:var(--fg); }
main { padding:20px; max-width:1200px; margin:0 auto; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:16px; margin-bottom:16px; }
.card h2 { font-size:14px; margin:0 0 12px; color:var(--muted); font-weight:600; }
table { width:100%; border-collapse:collapse; }
th,td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); font-size:13px; }
th { color:var(--muted); font-weight:500; }
.tag { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; margin:2px; }
.tag-green { background:#143b2b; color:var(--ok); }
.tag-red { background:#3b1418; color:var(--err); }
.tag-blue { background:#14223b; color:var(--accent); }
button { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:7px 14px; cursor:pointer; font:inherit; }
button.ghost { background:transparent; border:1px solid var(--line); color:var(--muted); }
button.danger { background:transparent; border:1px solid #3b1418; color:var(--err); }
input,select,textarea { width:100%; background:#10131a; border:1px solid var(--line); color:var(--fg);
border-radius:6px; padding:7px 10px; font:inherit; margin-bottom:8px; }
textarea { min-height:220px; resize:vertical; }
label { display:block; font-size:12px; color:var(--muted); margin:10px 0 4px; }
.row { display:flex; gap:12px; } .row > div { flex:1; }
.model-row { display:flex; gap:8px; align-items:center; }
.model-row input { margin:0; } .model-row .del { flex:0 0 auto; padding:4px 8px; }
.muted { color:var(--muted); }
.hidden { display:none; }
#toast { position:fixed; bottom:20px; right:20px; background:var(--card); border:1px solid var(--line);
padding:10px 16px; border-radius:8px; display:none; }
</style>
</head>
<body>
<header>
<h1>llmsproxy</h1>
<span class="badge">统一 LLM 网关 · 适配器/源管理</span>
</header>
<nav>
<button data-tab="status" class="active">状态</button>
<button data-tab="sources"></button>
<button data-tab="adapters">适配器</button>
</nav>
<main>
<div id="tab-status"></div>
<div id="tab-sources" class="hidden"></div>
<div id="tab-adapters" class="hidden"></div>
</main>
<div id="toast"></div>
<script>
const $ = s => document.querySelector(s);
const tab = () => document.querySelector('nav button.active').dataset.tab;
const api = (p, o) => fetch(p, o).then(async r => {
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error((j.error && j.error.message) || r.statusText);
return j;
});
function toast(m) { const t = $('#toast'); t.textContent = m; t.style.display = 'block'; setTimeout(() => t.style.display = 'none', 3000); }
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x === b));
['status','sources','adapters'].forEach(t => $('#tab-' + t).classList.toggle('hidden', t !== b.dataset.tab));
refresh(b.dataset.tab);
});
async function refresh(t) {
if (t === 'status') return renderStatus();
if (t === 'sources') return renderSources();
return renderAdapters();
}
async function renderStatus() {
const s = await api('/api/status');
const src = s.sources.map(x => `<tr><td>${esc(x.name)}</td><td>${esc(x.adapter)}</td>
<td>${x.models.map(m => `<span class="tag tag-blue">${esc(m)}</span>`).join('')}</td>
<td>${x.available ? '<span class="tag tag-green">可用</span>' : '<span class="tag tag-red">退避/不可用</span>'}</td>
<td>${x.max_concurrent}</td></tr>`).join('');
$('#tab-status').innerHTML = `
<div class="card"><h2>网关</h2>
<div class="muted">默认模型: ${esc(s.default_model)}</div>
<div class="muted">模型列表: ${s.models.map(esc).join(', ')}</div>
</div>
<div class="card"><h2>源状态 (${s.sources.length})</h2>
<table><tr><th>名称</th><th>适配器</th><th>模型</th><th>健康</th><th>并发</th></tr>${src}</table>
</div>
<div class="card"><h2>已加载适配器 (${s.adapters.length})</h2>
<table><tr><th>名称</th><th>版本</th></tr>
${s.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td></tr>`).join('')}
</table></div>`;
}
async function renderSources() {
const j = await api('/api/sources');
const rows = j.sources.map(s => `<tr><td>${esc(s.name)}</td><td>${esc(s.base_url)}</td><td>${esc(s.adapter)}</td>
<td>${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}<span class="muted">·${m.priority||0}</span></span>`).join('')}</td>
<td><button class="ghost" onclick="editSource(${JSON.stringify(s.name).replace(/"/g,'&quot;')})">编辑</button>
<button class="danger" onclick="delSource('${escAttr(s.name)}')">删除</button></td></tr>`).join('');
$('#tab-sources').innerHTML = `
<div class="card"><h2>源 (${j.sources.length})</h2>
<table><tr><th>名称</th><th>地址</th><th>适配器</th><th>模型 (优先级)</th><th></th></tr>${rows}</table>
<p><button onclick="editSource('')">+ 新增源</button></p>
</div>`;
}
async function renderAdapters() {
const j = await api('/api/status');
const rows = j.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td>
<td><button class="danger" onclick="delAdapter('${escAttr(a.name)}')">删除</button></td></tr>`).join('');
$('#tab-adapters').innerHTML = `
<div class="card"><h2>已加载适配器 (${j.adapters.length})</h2>
<table><tr><th>名称</th><th>版本</th><th></th></tr>${rows}</table>
</div>
<div class="card"><h2>上传 Lua 适配器</h2>
<label>名称(脚本保存为 <code>&lt;name&gt;.lua</code></label>
<input id="adp-name" placeholder="如 mysrc">
<label>Lua 脚本(返回 adapter table支持 transform_request/response/stream_chunk/build_headers</label>
<textarea id="adp-code" spellcheck="false" placeholder="return { name='mysrc', endpoint='/chat/completions', transform_request=function(raw) return raw end, transform_response=function(raw) return raw end }"></textarea>
<p><button onclick="uploadAdapter()">上传并加载</button></p>
</div>`;
}
function editSource(name) {
const modal = document.createElement('div');
const existing = name ? null : null;
// We'll re-fetch and prefill
api('/api/sources').then(j => {
const s = j.sources.find(x => x.name === name) || { name: name, models: [{ id: '', priority: 0, kind: 'chat' }] };
const modelInputs = (s.models || []).map((m, i) => modelRow(m, i)).join('');
modal.innerHTML = `<div class="card"><h2>${name ? '编辑源: ' + esc(name) : '新增源'}</h2>
<label>名称</label><input id="s-name" value="${escAttr(s.name)}" ${name ? 'disabled' : ''}>
<label>Base URL</label><input id="s-url" value="${escAttr(s.base_url || '')}">
<label>API Key</label><input id="s-key" type="password" value="${escAttr(s.api_key || '')}">
<label>适配器(对应已加载的 Lua 适配器名)</label><input id="s-adapter" value="${escAttr(s.adapter || 'openai')}">
<div class="row">
<div><label>聊天端点 (可选覆盖)</label><input id="s-ep" value="${escAttr(s.endpoint || '')}"></div>
<div><label>生图端点 (可选覆盖)</label><input id="s-img" value="${escAttr(s.image_endpoint || '')}"></div>
</div>
<div class="row">
<div><label>并发上限</label><input id="s-conc" type="number" value="${s.max_concurrent || 8}"></div>
<div><label>温度</label><input id="s-temp" type="number" step="0.1" value="${s.temperature || 0.7}"></div>
</div>
<label>模型列表(优先级数字越大越优先被 AUTO 选中)</label>
<div id="s-models">${modelInputs}</div>
<button class="ghost" onclick="addModelRow()">+ 模型</button>
<label>Meta透传给 build_headers 钩子JSON</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
<p><button onclick="saveSource(this)">保存</button> <button class="ghost" onclick="modal.remove()">取消</button></p>
</div>`;
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:40px 20px;z-index:50';
modal.id = 'modal';
document.body.appendChild(modal);
window._modal = modal;
window._models = s.models || [];
});
}
function modelRow(m, i) {
return `<div class="model-row">
<input data-mi="${i}" class="m-id" placeholder="模型 id如 deepseek-v4-flash" value="${escAttr(m.id)}">
<input data-mi="${i}" class="m-prio" type="number" placeholder="优先级" value="${m.priority || 0}" style="width:90px">
<select data-mi="${i}" class="m-kind"><option ${(m.kind==='image')?'':'selected'} value="chat">chat</option><option ${(m.kind==='image')?'selected':''} value="image">image</option></select>
<button class="ghost del" data-mi="${i}" onclick="this.closest('.model-row').remove()">×</button>
</div>`;
}
function addModelRow() {
const div = $('#s-models');
div.insertAdjacentHTML('beforeend', modelRow({ id: '', priority: 0, kind: 'chat' }, div.children.length));
}
async function saveSource(btn) {
const models = [...document.querySelectorAll('#s-models .model-row')].map(row => ({
id: row.querySelector('.m-id').value.trim(),
priority: parseInt(row.querySelector('.m-prio').value) || 0,
kind: row.querySelector('.m-kind').value,
})).filter(m => m.id);
let meta = {};
try { meta = JSON.parse($('#s-meta').value || '{}'); } catch (e) { toast('Meta 不是合法 JSON'); return; }
const payload = {
name: $('#s-name').value.trim(),
base_url: $('#s-url').value.trim(),
api_key: $('#s-key').value.trim(),
adapter: $('#s-adapter').value.trim(),
endpoint: $('#s-ep').value.trim(),
image_endpoint: $('#s-img').value.trim(),
max_concurrent: parseInt($('#s-conc').value) || 8,
temperature: parseFloat($('#s-temp').value) || 0,
models, meta,
};
btn.disabled = true;
try {
await api('/api/sources', { method: 'POST', body: JSON.stringify(payload) });
toast('已保存并热重载');
window._modal && window._modal.remove();
renderSources();
} catch (e) { toast('保存失败: ' + e.message); btn.disabled = false; }
}
async function delSource(name) {
if (!confirm('删除源 ' + name + '?')) return;
await api('/api/sources/' + encodeURIComponent(name), { method: 'DELETE' });
toast('已删除');
renderSources();
}
async function uploadAdapter() {
const name = $('#adp-name').value.trim();
const code = $('#adp-code').value;
if (!name || !code) return toast('需要名称和脚本');
try {
await api('/api/adapters', { method: 'POST', body: JSON.stringify({ name, code }) });
toast('适配器已加载');
renderAdapters();
} catch (e) { toast('上传失败: ' + e.message); }
}
async function delAdapter(name) {
if (!confirm('删除适配器 ' + name + '?')) return;
await api('/api/adapters/' + encodeURIComponent(name), { method: 'DELETE' });
toast('已删除');
renderAdapters();
}
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
refresh('status');
</script>
</body>
</html>