Files
ModelRouter/internal/gateway/ui/index.html

792 lines
43 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>ModelRouter</title>
<style>
:root{
--bg:#f4f6fb; --card:#ffffff; --card2:#f9fafd; --line:#e3e6f0; --fg:#1d2434; --muted:#66708a;
--accent:#3f6ef5; --accent-h:#2f5ae0; --ok:#17a964; --warn:#b7791f; --err:#d64550;
--tag-ok:#e6f6ee; --tag-err:#fdecec; --tag-blue:#e8effc; --tag-warn:#fdf3e2;
--shadow:0 8px 30px rgba(28,44,94,.08);
}
html[data-theme="dark"]{
--bg:#0e121d; --card:#161b2b; --card2:#111522; --line:#242b40; --fg:#e7eaf3; --muted:#8c94ac;
--accent:#4f7cff; --accent-h:#6a8ffb; --ok:#4f7cff;
--tag-ok:#12301f; --tag-err:#3d1a1c; --tag-blue:#182744; --tag-warn:#3b2f14;
--shadow:0 10px 34px rgba(0,0,0,.4);
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
transition:background .2s,color .2s; }
a { color:var(--accent); }
header { display:flex; align-items:center; gap:14px; padding:14px 24px; border-bottom:1px solid var(--line);
background:var(--card); position:sticky; top:0; z-index:20; }
.brand { display:flex; align-items:center; gap:12px; }
.logo { width:34px; height:34px; border-radius:10px; flex:0 0 auto;
background:linear-gradient(135deg,var(--accent),#6a8ffb); color:#fff; font-weight:700; font-size:17px;
display:flex; align-items:center; justify-content:center; box-shadow:0 4px 12px rgba(63,110,245,.35); }
.brand h1 { font-size:17px; margin:0; letter-spacing:.2px; }
.brand .sub { font-size:12px; color:var(--muted); margin-top:1px; }
.spacer { flex:1; }
.hd-actions { display:flex; align-items:center; gap:8px; }
.hd-actions button { background:var(--card2); border:1px solid var(--line); color:var(--muted);
padding:6px 12px; border-radius:8px; cursor:pointer; font:inherit; transition:border .15s,color .15s; }
.hd-actions button:hover { border-color:var(--accent); color:var(--accent); }
nav { display:flex; gap:6px; padding:14px 24px 0; max-width:1240px; margin:0 auto; }
nav button { background:transparent; border:1px solid transparent; color:var(--muted); padding:7px 16px;
cursor:pointer; border-radius:9px; font:inherit; font-weight:500; transition:all .15s; }
nav button:hover { color:var(--fg); background:var(--card); }
nav button.active { background:var(--card); border-color:var(--line); color:var(--accent); box-shadow:var(--shadow); }
main { padding:20px 24px 48px; max-width:1240px; margin:0 auto; }
.card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:20px; margin-bottom:18px;
box-shadow:var(--shadow); transition:background .2s; }
.card h2 { font-size:14px; margin:0 0 14px; color:var(--muted); font-weight:600;
display:flex; align-items:center; gap:8px; }
.card h2 .grow { flex:1; }
table { width:100%; border-collapse:collapse; }
th,td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--line); font-size:13px; }
th { color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.4px; }
tr:last-child td { border-bottom:0; }
.tag { display:inline-block; padding:2px 9px; border-radius:20px; font-size:12px; margin:2px 3px 2px 0; font-weight:500; }
.tag-green { background:var(--tag-ok); color:var(--ok); }
.tag-red { background:var(--tag-err); color:var(--err); }
.tag-blue { background:var(--tag-blue); color:var(--accent); }
.tag-warn { background:var(--tag-warn); color:var(--warn); }
button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:8px 16px; cursor:pointer;
font:inherit; font-weight:500; transition:background .15s; }
button:hover { background:var(--accent-h); }
button.ghost { background:transparent; border:1px solid var(--line); color:var(--muted); }
button.ghost:hover { border-color:var(--accent); color:var(--accent); background:transparent; }
button.danger { background:transparent; border:1px solid var(--err); color:var(--err); }
button.danger:hover { background:var(--err); color:#fff; }
button.small { padding:5px 12px; font-size:12px; border-radius:7px; }
input,select,textarea { width:100%; background:var(--card2); border:1px solid var(--line); color:var(--fg);
border-radius:8px; padding:9px 12px; font:inherit; margin-bottom:10px; outline:none;
transition:border .15s,box-shadow .15s; }
input:focus,select:focus,textarea:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(63,110,245,.15); }
textarea { min-height:200px; resize:vertical; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12.5px; }
select { cursor:pointer; }
label { display:block; font-size:12px; color:var(--muted); margin:12px 0 5px; }
.row { display:flex; gap:12px; } .row > div { flex:1; }
.model-row { display:flex; gap:8px; align-items:center; }
.model-row input, .model-row select { margin:0 0 8px; }
.model-row .del { flex:0 0 auto; padding:5px 9px; }
.muted { color:var(--muted); }
.hidden { display:none; }
.hidden#tab-chat { display:none; }
#toast { position:fixed; bottom:24px; right:24px; background:var(--card); border:1px solid var(--line);
padding:11px 18px; border-radius:10px; box-shadow:var(--shadow); display:none; animation:fadein .2s; z-index:100; }
@keyframes fadein { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
pre.configbox { background:var(--card2); border:1px solid var(--line); border-radius:10px; padding:14px; margin:10px 0;
overflow:auto; white-space:pre-wrap; word-break:break-all; font-size:12.5px; font-family:ui-monospace,Menlo,Consolas,monospace; }
.chatlog { height:280px; overflow:auto; background:var(--card2); border:1px solid var(--line);
border-radius:10px; padding:14px; margin-bottom:10px; font-size:13.5px; }
.chatlog .u { color:var(--accent); font-weight:500; }
.chatlog .a { color:var(--ok); }
.chatlog .meta { color:var(--muted); font-size:12px; margin-top:4px; }
.attbox { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:10px; }
.att { position:relative; display:inline-block; }
.att img { display:block; height:64px; border-radius:8px; border:1px solid var(--line); max-width:120px; object-fit:cover; }
.att button { position:absolute; top:-8px; right:-8px; border-radius:50%; }
.modelchip { cursor:pointer; color:var(--accent); text-decoration:none; }
/* ---------- chat page (clean) ---------- */
.tab-chat { max-width:840px; margin:0 auto; display:flex; flex-direction:column; height:calc(100vh - 178px); }
.chat-wrap { background:var(--card); border:1px solid var(--line); border-radius:12px; box-shadow:var(--shadow);
display:flex; flex-direction:column; flex:1; min-height:0; overflow:hidden; }
.chat-log { flex:1; overflow-y:auto; padding:20px 20px 8px; display:flex; flex-direction:column; gap:18px; }
.msg { display:flex; gap:10px; max-width:100%; }
.msg.usr { flex-direction:row-reverse; }
.avatar { width:28px; height:28px; border-radius:8px; flex:0 0 auto; display:flex; align-items:center; justify-content:center;
font-size:12px; font-weight:600; color:#fff; letter-spacing:.3px; }
.avatar.u { background:var(--accent); }
.avatar.a { background:var(--ok); }
.bubble { max-width:84%; padding:10px 14px; border-radius:12px; position:relative; word-break:break-word; font-size:13.5px; line-height:1.6; }
.msg.usr .bubble { background:var(--accent); color:#fff; border-top-right-radius:3px; }
.msg.ass .bubble { background:var(--card2); border:1px solid var(--line); border-top-left-radius:3px; }
.bubble .bmd p { margin:0 0 8px; } .bubble .bmd p:last-child { margin:0; }
.bubble .bmd h1,.bubble .bmd h2,.bubble .bmd h3,.bubble .bmd h4 { margin:9px 0 5px; font-size:1.04em; }
.bubble .bmd ul,.bubble .bmd ol { margin:0 0 8px; padding-left:20px; }
.bubble .bmd li { margin:2px 0; }
.bubble .bmd code { background:var(--bg); border:1px solid var(--line); border-radius:4px; padding:1px 5px; font-size:11.5px;
font-family:ui-monospace,Menlo,Consolas,monospace; }
.msg.usr .bubble .bmd code { background:rgba(0,0,0,.22); border-color:transparent; }
.bubble .bmd pre { background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:10px 12px; overflow:auto; margin:8px 0; }
.bubble .bmd pre code { background:transparent; border:0; padding:0; }
.bubble .bmd blockquote { margin:8px 0; padding-left:12px; border-left:3px solid var(--line); color:var(--muted); }
.bubble .bmd a { color:var(--accent); text-decoration:none; } .bubble .bmd a:hover { text-decoration:underline; }
.msg .muted { font-size:11.5px; margin-top:4px; opacity:.75; }
.think { color:var(--muted); border-left:2px solid var(--line); padding:4px 0 4px 12px; margin-top:8px; font-size:12px;
max-height:180px; overflow:auto; white-space:pre-wrap; }
.tk-hint { font-size:11px; display:block; margin-bottom:2px; opacity:.85; text-transform:none; }
.cursor-dot { display:inline-block; width:2px; height:14px; background:var(--fg); margin-left:3px; vertical-align:-2px;
animation:blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity:0 } }
.chat-composer { border-top:1px solid var(--line); padding:10px 12px 12px; background:var(--card); }
.chat-tools { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
.chat-tools .tl { font-size:12px; color:var(--muted); white-space:nowrap; }
.chat-tools select { width:auto; margin:0; padding:4px 10px; font-size:12.5px; border-radius:7px; }
.chat-tools .grow { flex:1; }
.chk { display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--muted); cursor:pointer; white-space:nowrap; user-select:none; }
.chk input { width:14px; height:14px; margin:0; accent-color:var(--accent); }
.chat-box { display:flex; gap:9px; align-items:flex-end; }
.chat-box textarea { flex:1; margin:0; min-height:42px; max-height:140px; resize:none; padding:10px 13px; border-radius:10px;
font:inherit; line-height:1.5; }
.chat-box .sendbtn { width:auto; padding:10px 20px; border-radius:10px; font-weight:600; white-space:nowrap; }
.chat-box .sendbtn:disabled { opacity:.5; cursor:default; }
.attach-btn { background:transparent; border:1px solid var(--line); color:var(--muted); border-radius:10px;
width:42px; height:42px; display:flex; align-items:center; justify-content:center; cursor:pointer; flex:0 0 auto;
transition:all .15s; }
.attach-btn:hover { border-color:var(--accent); color:var(--accent); }
.chat-empty { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--muted); gap:6px; }
.chat-empty .ring { width:42px; height:42px; margin-bottom:6px; border-radius:50%;
border:1px solid var(--line); display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:17px; opacity:.8; }
.chat-empty p { margin:0; }
.modelchip:hover { text-decoration:underline; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.dropzone { border:2px dashed var(--line); border-radius:10px; padding:26px; text-align:center; color:var(--muted);
cursor:pointer; transition:all .15s; background:var(--card2); }
.dropzone.dragover, .dropzone:hover { border-color:var(--accent); color:var(--accent); background:#eef2ff; }
html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:hover { background:#1a2138; }
.dropzone b { font-size:14px; display:block; margin-bottom:4px; }
.empty { color:var(--muted); text-align:center; padding:24px 0; }
#modal-wrap { position:fixed; inset:0; background:rgba(15,22,44,.45); display:flex; align-items:flex-start;
justify-content:center; overflow:auto; padding:48px 20px; z-index:50; }
.twrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
/* ---------- responsive / narrow screens ---------- */
@media (max-width: 900px) {
header { padding:12px 16px; }
nav { padding:12px 16px 0; overflow-x:auto; }
nav button { padding:7px 12px; white-space:nowrap; }
main { padding:16px 16px 40px; }
.card { padding:16px; }
}
@media (max-width: 640px) {
header { gap:8px; padding:10px 12px; }
.brand h1 { font-size:15px; }
.brand .sub { display:none; }
.hd-actions button { padding:5px 9px; }
nav { gap:4px; padding:10px 12px 0; }
nav button { padding:6px 10px; font-size:13px; }
main { padding:12px 12px 32px; }
.card { padding:13px; border-radius:12px; margin-bottom:14px; }
.card h2 { font-size:13px; }
th,td { padding:8px 10px; }
.row { flex-direction:column; gap:0; }
.model-row { flex-wrap:wrap; }
.model-row input { flex:1 1 120px; }
.model-row select { flex:0 0 auto; }
.tab-chat { height:calc(100vh - 150px); }
.msg { gap:7px; }
.avatar { width:24px; height:24px; font-size:11px; }
.bubble { max-width:90%; padding:8px 11px; font-size:13px; }
.chat-log { padding:14px 12px 6px; gap:14px; }
.chat-tools { flex-wrap:wrap; gap:8px; }
.chat-tools .tl { display:none; }
.chat-tools select { flex:1 1 auto; min-width:0; }
.chat-box { gap:7px; }
.chat-box .sendbtn { padding:10px 13px; }
.attach-btn { width:38px; height:38px; }
.chat-box textarea { font-size:13.5px; }
.chat-composer { padding:8px 9px 10px; }
#modal-wrap { padding:14px 10px; }
.dropzone { padding:18px 14px; }
#toast { left:12px; right:12px; bottom:12px; text-align:center; }
pre.configbox { font-size:11.5px; padding:11px; }
.att img { height:52px; max-width:90px; }
}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo">M</div>
<div><h1>ModelRouter</h1><div class="sub" data-i="tagline">统一 LLM 网关</div></div>
</div>
<div class="spacer"></div>
<div class="hd-actions">
<button id="btn-theme" title="theme"></button>
<button id="btn-lang">EN</button>
<button class="ghost" id="btn-logout" data-i="logout">退出登录</button>
</div>
</header>
<nav>
<button data-tab="status" class="active" data-i="navStatus">状态</button>
<button data-tab="chat" data-i="navChat">Chat 测试</button>
<button data-tab="sources" data-i="navSources"></button>
<button data-tab="adapters" data-i="navAdapters">适配器</button>
</nav>
<main>
<div id="tab-status"></div>
<div id="tab-chat" class="hidden"></div>
<div id="tab-sources" class="hidden"></div>
<div id="tab-adapters" class="hidden"></div>
</main>
<div id="toast"></div>
<script>
/* ---------- i18n ---------- */
const STR = {
zh: {
tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN',
navStatus:'状态', navChat:'Chat 测试', navSources:'源', navAdapters:'适配器',
connTitle:'连接配置Agent / OpenAI SDK', connHint:'模型名默认 AUTO按优先级自动选择可用源点击任一模型可生成固定到该模型的配置。',
copyCfg:'一键复制配置', copyEnv:'复制为环境变量',
srcTitle:'源状态', srcCount:'共 %d 个',
tName:'名称', tAdapter:'适配器', tModels:'模型(点击看配置)', tURL:'地址', tConn:'连接', tConc:'并发',
online:'在线', offline:'退避 / 不可用',
adTitle:'已加载适配器', tVersion:'版本',
chatTitle:'Chat 测试', cModel:'模型', cMsg:'Enter 发送Shift+Enter 换行)', send:'发送', clear:'清空',
cThinking:'推理', cImg:'图片', cPick:'选择图片', cRemoveImg:'移除', cWelcome:'开始对话——选择模型,输入消息即可测试上游连接',
cStream:'调用 /v1/chat/completions流式 SSE',
srcEmpty:'还没有配置任何源',
srcAdd:'+ 新增源', srcEdit:'编辑', srcDel:'删除',
adEmpty:'尚未加载适配器',
uploadTitle:'上传 / 拖拽 Lua 适配器', dropHint:'拖拽 .lua 文件到此处,或点击选择文件',
adName:'名称(保存为 &lt;名称&gt;.lua', tbLua:'Lua 脚本(返回 adapter table', uploadBtn:'上传并加载',
modalNew:'新增源', modalEdit:'编辑源',
mName:'名称', mURL:'Base URL', mKey:'API Key', mAlias:'适配器', mAliasAuto:'自动', mEp:'聊天端点(可选覆盖)',
mImgEp:'生图端点(可选覆盖)', mConc:'并发上限', mTemp:'温度',
mModels:'模型列表优先级越大AUTO 越优先选择)', mAddModel:'+ 模型',
mMeta:'Meta透传给 build_headers 钩子JSON', mSave:'保存', mCancel:'取消',
toastCopied:'已复制', toastCopyFail:'复制失败,请手动选择复制', toastSaved:'已保存并热重载',
toastEmpty:'请输入消息', toastBadJson:'Meta 不是合法 JSON', toastSaveFail:'保存失败: %s',
toastUploaded:'适配器已加载', toastUpFail:'上传失败: %s', toastNeedAll:'需要适配器名称和脚本',
toastDelOk:'已删除', confirmDelSrc:'确定删除源 %s 吗?', confirmDelAdp:'确定删除适配器 %s 吗?',
u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s',
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s (源 %s)', connAuto:'# 模型名 AUTO自动选择可用源',
},
en: {
tagline:'Unified LLM Gateway', logout:'Log out', langTo:'中',
navStatus:'Status', navChat:'Chat', navSources:'Sources', navAdapters:'Adapters',
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — picks the best healthy source by priority. Click a model to pin it.',
copyCfg:'Copy config', copyEnv:'Copy as env vars',
srcTitle:'Sources', srcCount:'%d total',
tName:'Name', tAdapter:'Adapter', tModels:'Models', tURL:'URL', tConn:'Status', tConc:'Concurrency',
online:'online', offline:'backoff / down',
adTitle:'Loaded adapters', tVersion:'Version',
chatTitle:'Chat', cModel:'Model', cMsg:'(Enter to send, Shift+Enter for newline)', send:'Send', clear:'Clear',
cThinking:'Thinking', cImg:'Image', cPick:'Pick image', cRemoveImg:'Remove', cWelcome:'Start a conversation — pick a model to test the upstream connection',
cStream:'calls /v1/chat/completions (streaming SSE)',
srcEmpty:'No sources configured yet',
srcAdd:'+ Add source', srcEdit:'Edit', srcDel:'Delete',
adEmpty:'No adapters loaded',
uploadTitle:'Upload / drag a Lua adapter', dropHint:'Drag a .lua file here, or click to choose',
adName:'Name (saved as <name>.lua)', tbLua:'Lua script (returns adapter table)', uploadBtn:'Upload & load',
modalNew:'Add source', modalEdit:'Edit source',
mName:'Name', mURL:'Base URL', mKey:'API Key', mAlias:'Adapter', mAliasAuto:'Auto', mEp:'Chat endpoint (override)',
mImgEp:'Image endpoint (override)', mConc:'Max concurrency', mTemp:'Temperature',
mModels:'Models (higher priority → preferred)', mAddModel:'+ model',
mMeta:'Meta (passed to build_headers hook, JSON)', mSave:'Save', mCancel:'Cancel',
toastCopied:'Copied', toastCopyFail:'Copy failed', toastSaved:'Saved & hot-reloaded',
toastEmpty:'Enter a message', toastBadJson:'Meta is not valid JSON', toastSaveFail:'Save failed: %s',
toastUploaded:'Adapter loaded', toastUpFail:'Upload failed: %s', toastNeedAll:'Adapter name and code required',
toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?',
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'(empty)', aErr:'Request failed: %s',
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" picks healthy source by priority',
}
};
let LANG = localStorage.getItem('llms-proxy.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
function t(key){ return (STR[LANG] || STR.zh)[key] !== undefined ? (STR[LANG] || STR.zh)[key] : key; }
function tFmt(key){ const s = String(t(key)); const args = Array.prototype.slice.call(arguments, 1);
return s.replace(/%s|%d/g, (m) => { const v = args.shift(); return (v === undefined) ? (m === '%d' ? '0' : '') : v; }); }
function applyI18n() {
document.querySelectorAll('[data-i]').forEach(el => {
const s = t(el.dataset.i);
if (el.dataset.i === 'logout') el.textContent = s;
else el.textContent = s;
});
document.getElementById('btn-lang').textContent = LANG === 'zh' ? 'EN' : '中';
document.documentElement.lang = LANG;
}
let lastTab = 'status';
function activeTab(){ return lastTab; }
/* ---------- theme & header ---------- */
const THEME = localStorage.getItem('llms-proxy.theme') || 'light';
document.documentElement.dataset.theme = THEME;
document.getElementById('btn-theme').onclick = () => {
const nx = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = nx;
localStorage.setItem('llms-proxy.theme', nx);
};
document.getElementById('btn-lang').onclick = () => {
LANG = LANG === 'zh' ? 'en' : 'zh';
localStorage.setItem('llms-proxy.lang', LANG);
applyI18n(); refresh(lastTab);
};
document.getElementById('btn-logout').onclick = () => { location.href = '/api/logout'; };
document.querySelectorAll('nav button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x === b));
['status','chat','sources','adapters'].forEach(tn => $('#tab-' + tn).classList.toggle('hidden', tn !== b.dataset.tab));
lastTab = b.dataset.tab; refresh(lastTab);
};
});
applyI18n();
/* ---------- helpers ---------- */
const $ = s => document.querySelector(s);
const api = (p, o) => fetch(p, o).then(async r => {
if (r.status === 401) { location.href = '/login?continue=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); }
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error((j.error && j.error.message) || r.statusText);
return j;
});
let toastTimer;
function toast(m) { const el = $('#toast'); el.textContent = m; el.style.display = 'block';
clearTimeout(toastTimer); toastTimer = setTimeout(() => el.style.display = 'none', 2600); }
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
function copyText(txt, okMsg) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(() => toast(okMsg || t('toastCopied')), () => toast(t('toastCopyFail')));
} else { toast(t('toastCopyFail')); }
}
/* ---------- status tab ---------- */
async function renderStatus() {
const s = await api('/api/status');
const base = window._base = s.base_url || location.origin + '/v1';
const key = window._key = (s.gateway_keys && s.gateway_keys[0]) || '';
const srcRows = s.sources.map(x =>
`<tr><td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
<td>${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</td>
<td><span class="muted">${esc(x.base_url || '')}</span></td>
<td>${x.available ? `<span class="tag tag-green">${t('online')}</span>` : `<span class="tag tag-red">${t('offline')}</span>`}</td>
<td>${x.max_concurrent}</td></tr>`).join('');
$('#tab-status').innerHTML = `
<div class="card"><h2>${t('connTitle')}</h2>
<div class="muted">${t('connHint')}</div>
<pre class="configbox" id="conncfg"></pre>
<p><button onclick="copyCfg()">${t('copyCfg')}</button>
<button class="ghost" onclick="copyEnv()">${t('copyEnv')}</button></p>
<label>${t('tModels')}</label>
<div class="chips" id="model-chips"></div>
</div>
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConn')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table>
</div>
<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tVersion')}</th></tr>
${s.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td></tr>`).join('')}</table>
</div>`;
$('#conncfg').textContent = '';
showModelConfig(null, '');
$('#model-chips').innerHTML = s.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig(null,'${escAttr(m)}')">${esc(m)}</span>`).join('');
}
function showModelConfig(srcName, model) {
const el = $('#conncfg'); if (!el) return;
const base = window._base, key = window._key;
const m = model || 'AUTO';
const json = JSON.stringify({ base_url: base, api_key: key || 'YOUR_GATEWAY_KEY', model: m }, null, 2);
let head;
if (model) head = tFmt('connPinned', model, srcName || '');
else head = tFmt('connAuto');
el.textContent = head + '\n' + json;
window._lastCfg = {
json,
env: 'OPENAI_BASE_URL=' + base + '\nOPENAI_API_KEY=' + (key || 'YOUR_GATEWAY_KEY') + '\nOPENAI_MODEL=' + m,
};
}
function copyCfg() { copyText(window._lastCfg ? window._lastCfg.json : ''); }
function copyEnv() { copyText(window._lastCfg ? window._lastCfg.env : ''); }
/* ---------- chat tab ---------- */
let chatHistory = [];
let chatImages = [];
async function renderChat() {
const s = await api('/api/status');
const opts = ['AUTO', ...(s.models || [])].map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('');
$('#tab-chat').innerHTML = `
<div class="tab-chat">
<div class="chat-wrap">
<div class="chat-log" id="ct-log">
<div class="chat-empty" id="ct-empty">
<div class="ring">M</div>
<p>${esc(t('cWelcome'))}</p>
<p class="muted" style="font-size:12px">${esc(t('cStream'))}</p>
</div>
</div>
<div class="chat-composer">
<div class="chat-tools">
<span class="tl">${t('cModel')}</span>
<select id="ct-model">${opts}</select>
<span class="grow"></span>
<label class="chk"><input id="ct-thinking" type="checkbox" checked>${t('cThinking')}</label>
</div>
<div class="attbox" id="ct-imgs"></div>
<div class="chat-box">
<button class="attach-btn" id="ct-pin" title="${escAttr(t('cPick'))}" onclick="$('#ct-file').click()">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
</button>
<input id="ct-file" type="file" accept="image/*" multiple hidden onchange="addChatImgs(this.files)">
<textarea id="ct-msg" rows="1" placeholder="${escAttr(t('cMsg'))}"></textarea>
<button class="sendbtn" id="ct-send" onclick="sendChat()">${t('send')}</button>
</div>
</div>
</div>
</div>`;
chatHistory = [];
chatImages = [];
updateGrow();
$('#ct-msg').addEventListener('input', updateGrow);
$('#ct-msg').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } });
$('#ct-imgs').style.display = 'none';
$('#ct-msg').focus();
}
function updateGrow() {
const ta = $('#ct-msg');
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
}
function addMsg(role, text, reason) {
const empty = $('#ct-empty'); if (empty) empty.remove();
const log = $('#ct-log');
const row = document.createElement('div');
row.className = 'msg ' + (role === 'user' ? 'usr' : 'ass');
const av = role === 'user' ? 'u' : 'a';
const label = role === 'user' ? '你' : 'AI';
let inner = `<div class="avatar ${av}">${label}</div>`;
if (role === 'user') {
inner += `<div class="bubble"><div class="bmd">&nbsp;</div></div>`;
} else {
inner += `<div class="bubble"><div class="bmd"></div>` +
(reason ? `<div class="think"><div class="tk-hint"></div></div>` : ``) + `</div>`;
}
row.innerHTML = inner;
log.appendChild(row);
scrollChat();
return row;
}
function scrollChat() { const log = $('#ct-log'); log.scrollTop = log.scrollHeight; }
function renderMD(el, text) {
// minimal markdown: code blocks, inline code, bold, italic, links, lists, headings, paragraphs
const blocks = text.split(/\n{2,}/);
const out = [];
let inCode = false;
let codeBuf = [];
for (const b of blocks) {
const cm = b.match(/^```([\w-]*)\r?\n([\s\S]*?)(?:```|$)/);
if (cm) {
out.push(`<pre><code>${esc(cm[2].replace(/\n$/, ''))}</code></pre>`);
continue;
}
if (b.trim().startsWith('```')) { inCode = !inCode; codeBuf.push(b); continue; }
if (inCode) { codeBuf.push(b); continue; }
if (codeBuf.length) {
out.push(`<pre><code>${esc(codeBuf.join('\n').replace(/^```.*\r?\n?/, '').replace(/\n?```$/, ''))}</code></pre>`);
inCode = false; codeBuf = [];
}
let line = b.trim();
if (!line) continue;
let html = esc(line);
// headings
html = html.replace(/^(#{1,4})\s+(.+)$/gm, (_, h, t) => `<h${h.length}>${t}</h${h.length}>`);
// bullets / lists
html = html.replace(/^(\s*)[-*]\s+(.+)$/gm, (_, ind, t) => `${ind}<li>${t}</li>`);
html = html.replace(/(?:^|\n)<li>/g, '<li>');
out.push(`<p>${linksAndBold(html)}</p>`);
}
if (codeBuf.length) {
out.push(`<pre><code>${esc(codeBuf.join('\n').replace(/^```.*\n?/, '').replace(/\n?```$/, ''))}</code></pre>`);
}
el.innerHTML = out.join('');
}
function linksAndBold(s) {
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
s = s.replace(/https?:\/\/[^\s<)]+/g, '<a href="$&" target="_blank" rel="noopener">$&</a>');
return s;
}
async function sendChat() {
const model = $('#ct-model').value || 'AUTO';
const msgTa = $('#ct-msg');
const msg = msgTa.value.trim();
if (!msg && chatImages.length === 0) return toast(t('toastEmpty'));
let content = msg;
if (chatImages.length > 0) {
content = [{ type: 'text', text: msg }];
chatImages.forEach(img => content.push({ type: 'image_url', image_url: { url: img } }));
}
chatHistory.push({ role: 'user', content });
msgTa.value = '';
msgTa.style.height = 'auto';
clearChatImg();
const urow = addMsg('user', msg);
const uimgBox = urow.querySelector('.bmd');
uimgBox.innerHTML = '';
if (Array.isArray(content)) {
const tx = content.filter(p => p.type === 'text').map(p => p.text).join(' ');
uimgBox.innerHTML = esc(tx);
content.filter(p => p.type === 'image_url').forEach(p => {
const img = document.createElement('img');
img.src = p.image_url.url; img.style.maxWidth = '120px'; img.style.borderRadius = '8px';
img.style.display = 'block'; img.style.marginTop = '6px';
uimgBox.appendChild(img);
});
} else {
uimgBox.textContent = msg;
}
const arow = addMsg('assistant', '');
const abuf = arow.querySelector('.bmd');
const thinkEl = arow.querySelector('.think');
const hintEl = arow.querySelector('.tk-hint');
const cursor = document.createElement('span'); cursor.className = 'cursor-dot';
abuf.appendChild(cursor);
const sendBtn = $('#ct-send');
sendBtn.disabled = true;
const started = Date.now(); let text = '', thought = '', firstChunk = Date.now();
try {
const resp = await fetch('/api/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, stream: true, disable_thinking: !$('#ct-thinking').checked, messages: chatHistory })});
if (resp.status === 401) { location.href = '/login'; return; }
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
cursor.remove();
abuf.innerHTML = esc(tFmt('cErr', j.error.message || resp.statusText || ''));
return;
}
const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = '';
let firstDelta = true;
while (true) {
const { done, value } = await reader.read(); if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const ev = buf.slice(0, i); buf = buf.slice(i + 2);
const line = ev.split('\n').find(l => l.startsWith('data:'));
if (!line) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') continue;
const ck = JSON.parse(data);
const delta = (ck.choices && ck.choices[0] && ck.choices[0].delta) || {};
if (firstDelta) { firstDelta = false; firstChunk = Date.now(); }
if (delta.reasoning_content) {
thought += delta.reasoning_content;
hintEl.textContent = t('at') + ' · ' + Math.round(thought.length / 3) + '…';
thinkEl.style.display = 'block';
thinkEl.textContent = thought.trim();
scrollChat();
}
if (delta.content) {
text += delta.content;
cursor.remove();
renderMD(abuf, text);
scrollChat();
}
}
}
cursor.remove();
const ms = Date.now() - started;
if (text) {
renderMD(abuf, text);
chatHistory.push({ role: 'assistant', content: text });
} else {
abuf.textContent = (thought ? '(仅思考) ' : '') + t('aEmpty');
}
const meta = document.createElement('div'); meta.className = 'muted';
meta.textContent = tFmt('chatMeta', model, ms, text.length);
abuf.appendChild(meta);
} catch (e) {
cursor.remove();
abuf.textContent = esc(tFmt('aErr', e.message));
} finally {
sendBtn.disabled = false;
}
}
function clearChat() { chatHistory = []; chatImages = []; const log = $('#ct-log'); if (log) log.innerHTML = ''; clearChatImg(); }
function clearChatImg() {
chatImages = [];
const box = $('#ct-imgs');
if (box) { box.innerHTML = ''; box.style.display = 'none'; }
}
function renderChatImgs() {
const box = $('#ct-imgs'); if (!box) return;
box.style.display = chatImages.length ? 'flex' : 'none';
box.innerHTML = chatImages.map((src, i) =>
`<span class="att"><img src="${escAttr(src)}"><button class="ghost small" title="${escAttr(t('cRemoveImg'))}" onclick="removeChatImg(${i})">&times;</button></span>`).join('');
}
function addChatImgs(files) {
for (const f of files) {
const rd = new FileReader();
rd.onload = () => { chatImages.push(rd.result); renderChatImgs(); };
rd.readAsDataURL(f);
}
const inp = $('#ct-file'); if (inp) inp.value = '';
}
function removeChatImg(i) { chatImages.splice(i, 1); renderChatImgs(); }
/* ---------- sources tab ---------- */
async function renderSources() {
const j = await api('/api/sources');
const rows = j.sources.map(s => `<tr><td><b>${esc(s.name)}</b></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 small" onclick="editSource(${JSON.stringify(s.name).replace(/"/g,'&quot;')})">${t('srcEdit')}</button>
<button class="danger small" onclick="delSource('${escAttr(s.name)}')">${t('srcDel')}</button></td></tr>`).join('');
$('#tab-sources').innerHTML = `
<div class="card"><h2><span>${t('srcTitle')}</span><span class="grow"></span>
<button class="small" onclick="editSource('')">${t('srcAdd')}</button></h2>
<table><tr><th>${t('tName')}</th><th>${t('tURL')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th></th></tr>
${rows || `<tr><td colspan="5" class="empty">${t('srcEmpty')}</td></tr>`}</table>
</div>`;
}
function editSource(name) {
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
Promise.all([api('/api/sources'), api('/api/status')]).then(([src, st]) => {
const s = src.sources.find(x => x.name === name) || { name: name, models: [{ id: '', priority: 0, kind: 'chat' }] };
const cur = s.adapter || 'openai';
const apps = ['', ...(st.adapters || []).map(a => a.name)];
if (cur && !apps.includes(cur)) apps.push(cur);
const adSel = `<select id="s-adapter"><option value="" ${!cur ? 'selected' : ''}>${esc(t('mAliasAuto'))}</option>` +
apps.filter(n => n).map(n => `<option value="${escAttr(n)}" ${n === cur ? 'selected' : ''}>${esc(n)}</option>`).join('') + '</select>';
wrap.innerHTML = `<div class="card"><h2>${esc(name ? t('modalEdit') + ': ' + name : t('modalNew'))}</h2>
<div class="row">
<div><label>${t('mName')}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? 'disabled' : ''}></div>
<div><label>${t('mAlias')}</label>${adSel}</div>
</div>
<label>${t('mURL')}</label><input id="s-url" value="${escAttr(s.base_url || '')}">
<label>${t('mKey')}</label><input id="s-key" type="password" value="${escAttr(s.api_key || '')}">
<div class="row">
<div><label>${t('mEp')}</label><input id="s-ep" value="${escAttr(s.endpoint || '')}"></div>
<div><label>${t('mImgEp')}</label><input id="s-img" value="${escAttr(s.image_endpoint || '')}"></div>
</div>
<div class="row">
<div><label>${t('mConc')}</label><input id="s-conc" type="number" value="${s.max_concurrent || 8}"></div>
<div><label>${t('mTemp')}</label><input id="s-temp" type="number" step="0.1" value="${s.temperature ?? 0.7}"></div>
</div>
<label>${t('mModels')}</label>
<div id="s-models"></div>
<button class="ghost small" onclick="addModelRow()">${t('mAddModel')}</button>
<label>${t('mMeta')}</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)">${t('mSave')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
document.body.appendChild(wrap);
const box = $('#s-models');
(s.models && s.models.length ? s.models : [{ id: '', priority: 0, kind: 'chat' }]).forEach((m, i) => box.insertAdjacentHTML('beforeend', modelRow(m, i)));
}).catch(() => {});
}
function modelRow(m, i) {
return `<div class="model-row">
<input data-mi="${i}" class="m-id" placeholder="model-id" value="${escAttr(m.id)}">
<input data-mi="${i}" class="m-prio" type="number" placeholder="priority" 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 small" onclick="this.closest('.model-row').remove()">&times;</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(t('toastBadJson')); 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(t('toastSaved'));
const w = $('#modal-wrap'); if (w) w.remove(); else if (window._modal) window._modal.remove();
renderSources();
} catch (e) { toast(tFmt('toastSaveFail', e.message)); btn.disabled = false; }
}
async function delSource(name) {
if (!confirm(tFmt('confirmDelSrc', name))) return;
await api('/api/sources/' + encodeURIComponent(name), { method: 'DELETE' });
toast(t('toastDelOk')); renderSources();
}
/* ---------- adapters tab ---------- */
async function renderAdapters() {
const j = await api('/api/status');
const rows = j.adapters.map(a => `<tr><td><b>${esc(a.name)}</b></td><td>${esc(a.version || '')}</td>
<td><button class="danger small" onclick="delAdapter('${escAttr(a.name)}')">${t('srcDel')}</button></td></tr>`).join('');
$('#tab-adapters').innerHTML = `
<div class="card"><h2>${t('adTitle')} (${j.adapters.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tVersion')}</th><th></th></tr>
${rows || `<tr><td colspan="3" class="empty">${t('adEmpty')}</td></tr>`}</table>
</div>
<div class="card"><h2>${t('uploadTitle')}</h2>
<div class="dropzone" id="dz">${t('dropHint')}</div>
<input type="file" id="adp-file" accept=".lua,text/x-lua" class="hidden">
<label>${t('adName')}</label><input id="adp-name" placeholder="mysrc">
<label>${t('tbLua')}</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()">${t('uploadBtn')}</button></p>
</div>`;
bindDropzone();
}
function bindDropzone() {
const dz = $('#dz'), file = $('#adp-file'), name = $('#adp-name'), code = $('#adp-code');
['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('dragover'); }));
['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('dragover'); }));
dz.addEventListener('drop', e => {
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) loadFile(f);
});
dz.onclick = () => file.click();
file.onchange = () => { if (file.files[0]) { loadFile(file.files[0]); file.value = ''; } };
function loadFile(f) {
if (!f.name.toLowerCase().endsWith('.lua')) { toast(tFmt('toastUpFail', 'not a .lua')); return; }
const reader = new FileReader();
reader.onload = () => {
name.value = f.name.replace(/\.lua$/i, '');
code.value = String(reader.result || '');
toast(f.name);
};
reader.readAsText(f);
}
}
async function uploadAdapter() {
const name = $('#adp-name').value.trim(), code = $('#adp-code').value;
if (!name || !code) return toast(t('toastNeedAll'));
try {
await api('/api/adapters', { method: 'POST', body: JSON.stringify({ name, code }) });
toast(t('toastUploaded'));
$('#adp-name').value = ''; $('#adp-code').value = '';
renderAdapters();
} catch (e) { toast(tFmt('toastUpFail', e.message)); }
}
async function delAdapter(name) {
if (!confirm(tFmt('confirmDelAdp', name))) return;
await api('/api/adapters/' + encodeURIComponent(name), { method: 'DELETE' });
toast(t('toastDelOk')); renderAdapters();
}
/* ---------- boot ---------- */
function refresh(tab) {
if (tab === 'status') return renderStatus();
if (tab === 'chat') return renderChat();
if (tab === 'sources') return renderSources();
return renderAdapters();
}
refresh('status');
</script>
</body>
</html>