mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
feat: LuaJIT worker-pool VM, multimodal/disable-thinking passthrough, WebUI redesign, DeepSeek V4
This commit is contained in:
@ -3,182 +3,652 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>llmsproxy</title>
|
||||
<title>ModelRouter</title>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --card:#171a21; --line:#262b36; --fg:#e6e8ee; --muted:#8b93a5;
|
||||
--accent:#4f7cff; --ok:#3ecf8e; --warn:#ffb454; --err:#ff5d6c; }
|
||||
: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; 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; }
|
||||
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: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; }
|
||||
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.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; }
|
||||
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 { margin:0; } .model-row .del { flex:0 0 auto; padding:4px 8px; }
|
||||
.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; }
|
||||
#toast { position:fixed; bottom:20px; right:20px; background:var(--card); border:1px solid var(--line);
|
||||
padding:10px 16px; border-radius:8px; 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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>llmsproxy</h1>
|
||||
<span class="badge">统一 LLM 网关 · 适配器/源管理</span>
|
||||
<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">状态</button>
|
||||
<button data-tab="sources">源</button>
|
||||
<button data-tab="adapters">适配器</button>
|
||||
<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:'名称(保存为 <名称>.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 tab = () => document.querySelector('nav button.active').dataset.tab;
|
||||
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;
|
||||
});
|
||||
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();
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
||||
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 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('');
|
||||
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>网关</h2>
|
||||
<div class="muted">默认模型: ${esc(s.default_model)}</div>
|
||||
<div class="muted">模型列表: ${s.models.map(esc).join(', ')}</div>
|
||||
<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>源状态 (${s.sources.length})</h2>
|
||||
<table><tr><th>名称</th><th>适配器</th><th>模型</th><th>健康</th><th>并发</th></tr>${src}</table>
|
||||
<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>已加载适配器 (${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>`;
|
||||
<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"> </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})">×</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>${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,'"')})">编辑</button>
|
||||
<button class="danger" onclick="delSource('${escAttr(s.name)}')">删除</button></td></tr>`).join('');
|
||||
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,'"')})">${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>源 (${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 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>`;
|
||||
}
|
||||
|
||||
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><name>.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')}">
|
||||
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>聊天端点 (可选覆盖)</label><input id="s-ep" value="${escAttr(s.endpoint || '')}"></div>
|
||||
<div><label>生图端点 (可选覆盖)</label><input id="s-img" value="${escAttr(s.image_endpoint || '')}"></div>
|
||||
<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>并发上限</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>${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>模型列表(优先级数字越大越优先被 AUTO 选中)</label>
|
||||
<div id="s-models">${modelInputs}</div>
|
||||
<button class="ghost" onclick="addModelRow()">+ 模型</button>
|
||||
<label>Meta(透传给 build_headers 钩子,JSON)</label>
|
||||
<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)">保存</button> <button class="ghost" onclick="modal.remove()">取消</button></p>
|
||||
<p><button onclick="saveSource(this)">${t('mSave')}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</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 || [];
|
||||
});
|
||||
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="模型 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">
|
||||
<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" data-mi="${i}" onclick="this.closest('.model-row').remove()">×</button>
|
||||
<button class="ghost del small" 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(),
|
||||
@ -186,55 +656,92 @@ async function saveSource(btn) {
|
||||
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; }
|
||||
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,
|
||||
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();
|
||||
toast(t('toastSaved'));
|
||||
const w = $('#modal-wrap'); if (w) w.remove(); else if (window._modal) window._modal.remove();
|
||||
renderSources();
|
||||
} catch (e) { toast('保存失败: ' + e.message); btn.disabled = false; }
|
||||
} catch (e) { toast(tFmt('toastSaveFail', e.message)); btn.disabled = false; }
|
||||
}
|
||||
|
||||
async function delSource(name) {
|
||||
if (!confirm('删除源 ' + name + '?')) return;
|
||||
if (!confirm(tFmt('confirmDelSrc', name))) return;
|
||||
await api('/api/sources/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
toast('已删除');
|
||||
renderSources();
|
||||
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();
|
||||
const code = $('#adp-code').value;
|
||||
if (!name || !code) return toast('需要名称和脚本');
|
||||
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('适配器已加载');
|
||||
toast(t('toastUploaded'));
|
||||
$('#adp-name').value = ''; $('#adp-code').value = '';
|
||||
renderAdapters();
|
||||
} catch (e) { toast('上传失败: ' + e.message); }
|
||||
} catch (e) { toast(tFmt('toastUpFail', e.message)); }
|
||||
}
|
||||
|
||||
async function delAdapter(name) {
|
||||
if (!confirm('删除适配器 ' + name + '?')) return;
|
||||
if (!confirm(tFmt('confirmDelAdp', name))) return;
|
||||
await api('/api/adapters/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
toast('已删除');
|
||||
renderAdapters();
|
||||
toast(t('toastDelOk')); renderAdapters();
|
||||
}
|
||||
|
||||
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
||||
|
||||
/* ---------- 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>
|
||||
|
||||
Reference in New Issue
Block a user