fix(webui,gui): repair dead export button + wire chat clear; prune UI redundancy

WebUI (internal/gateway/ui):
- BUG: the export modal's custom-range button called
  downloadStatsCsvFromForm() which was never defined — clicking it threw a
  ReferenceError and nothing downloaded. Implement it: reads #exp-from /
  #exp-to date inputs and forwards to downloadStatsCsv.
- BUG-adjacent: clearChat() existed but was reachable from no control —
  add a Clear button to the chat composer so conversation reset is actually
  possible (+ cClear i18n zh/en).
- remove byte-identical duplicate html[data-theme=dark] CSS block (15 lines)
- remove 8 dead CSS rules (.keys-grid .m-model-row .scope-add/.scope-box/
  .scope-chips .scr-blocks .tag-warn .twrap) and the never-consumed
  --accent custom property
- remove 3 dead JS functions (activeTab/findSlots/scopeUncomb; lastTab decl kept)
- remove 24 dead i18n keys x zh/en (~55 lines) — legacy of the replaced
  key-scope editor, matching the removed .scope-* styles

GUI (cmd/gui/main.js):
- BUG: stopCore() set app.isQuitting=true and nothing reset it — after using
  tray 'stop core', closing the window quit the whole app instead of hiding
  to tray, and core crash auto-restart stayed disabled. isQuitting now only
  flips in restartCore (scoped) and before-quit.

Verified: go vet/test green; node --check on all three GUI js files and the
WebUI inline script.
This commit is contained in:
dev
2026-08-24 23:13:54 +08:00
parent f3d5ba6cea
commit 334b984c25
2 changed files with 20 additions and 90 deletions

View File

@ -28,7 +28,6 @@
--sh-lg:0 18px 46px rgba(120,90,160,.22);
--glass:18px;
--ease-spring:cubic-bezier(.34,1.56,.64,1);
--accent:sakura;
}
html[data-accent="sakura"]{
--primary:#FF7FAC; --primary-h:#F33B7C; --primary-50:#FFF0F5; --primary-100:#FFE4E9;
@ -84,20 +83,6 @@
--blob1:rgba(168,85,247,.32); --blob2:rgba(34,211,238,.26); --blob3:rgba(139,92,246,.22);
--line:rgba(168,85,247,.16); --line-strong:rgba(139,92,246,.18);
}
html[data-theme="dark"]{
--primary:#f54180; --primary-h:#f871a0; --primary-50:#310413; --primary-100:#610726;
--primary-200:#920b3a; --primary-300:#c20e4d; --primary-400:#f31260; --primary-500:#f54180;
--secondary:#5E9FBF; --secondary-h:#88C0D0; --secondary-50:#0E2A36; --secondary-200:#2A546A;
--danger:#DB3694; --danger-50:#3d1a24; --ok:#4f7cff; --warn:#e0b25c; --info:#6a8ffb;
--bg-s1:#14121c; --bg-s2:#1a1824; --bg-s3:#221a28;
--card:rgba(34,31,50,.55); --card-solid:#241f38; --card2:rgba(44,39,64,.42);
--line:rgba(255,127,172,.16); --line-strong:rgba(200,180,235,.18);
--fg:#ece6f5; --muted:#a09db8;
--blob1:rgba(255,79,160,.32); --blob2:rgba(100,180,215,.26); --blob3:rgba(219,54,148,.22);
--sh-sm:0 1px 2px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.05);
--sh-md:0 8px 26px rgba(0,0,0,.45);
--sh-lg:0 20px 52px rgba(0,0,0,.55);
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;color:var(--fg);overflow:hidden;
@ -187,7 +172,6 @@
.tag-green{background:var(--primary-50);color:var(--ok)}
.tag-red{background:var(--danger-50);color:var(--danger)}
.tag-blue{background:var(--secondary-50);color:var(--secondary-h)}
.tag-warn{background:rgba(183,121,31,.14);color:var(--warn)}
.net-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;margin-right:6px;vertical-align:1px}
button{background:linear-gradient(135deg,var(--primary),var(--danger));color:#fff;border:0;border-radius:11px;padding:8px 16px;cursor:pointer;
font:inherit;font-weight:600;transition:all .18s var(--ease-spring)}
@ -278,7 +262,6 @@
.filter-line select{width:auto;margin:0;padding:6px 10px}
/* ---- keys tab ---- */
.keys-grid{display:flex;flex-direction:column;gap:12px}
.key-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:12px;background:var(--card);flex-wrap:wrap}
.key-row .kr-name{font-weight:700;min-width:120px}
.key-row .kr-key{font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;max-width:340px;white-space:nowrap}
@ -287,14 +270,11 @@
.tag-role{font-size:11px;padding:3px 10px;border-radius:99px;font-weight:700}
.tag-role.admin{background:linear-gradient(135deg,var(--primary),var(--danger));color:#fff}
.tag-role.user{background:var(--secondary-50);color:var(--secondary-h);border:1px solid var(--line)}
.scope-box{border-top:1px dashed var(--line);margin-top:8px;padding-top:10px}
.scope-chips{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-height:40px}
.scope-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:99px;background:linear-gradient(135deg,var(--primary),var(--secondary));color:#fff;font-size:12.5px;font-weight:600;cursor:grab;user-select:none}
.scope-chip[draggable="true"]:active{cursor:grabbing}
.scope-chip .rm{cursor:pointer;opacity:.75;font-size:13px;line-height:1;padding:0 2px}
.scope-chip .rm:hover{opacity:1}
.scope-chip.drag-over{outline:2px dashed #fff;outline-offset:2px}
.scope-add{padding:5px 10px;border-radius:99px;border:1px dashed var(--line);background:transparent;color:var(--muted);font-size:12.5px;cursor:pointer}
.scope-unlim{color:var(--primary-h);font-weight:700;font-size:13px}
.keys-create{display:grid;grid-template-columns:1fr 2fr 1fr 2fr;gap:10px;align-items:end}
.keys-create label{font-size:11.5px;color:var(--muted);margin-bottom:4px}
@ -383,7 +363,6 @@
#modal-wrap{position:fixed;inset:0;background:rgba(30,20,60,.45);display:none;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50;backdrop-filter:blur(3px)}
#modal-wrap:not(:empty){display:flex}
#modal-wrap .card{width:100%;max-width:480px;animation:cardIn .28s var(--ease-spring) both}
.twrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
/* ---- scratch-style priority canvas (sakura restyle, same classes) ---- */
.sort-canvas{position:relative;min-height:420px;padding:22px 14px 30px;margin:4px 0 10px;background-color:var(--card2);
@ -395,7 +374,6 @@
.scr-lane.scr-top .scr-tier span{opacity:.95}
.scr-row{flex:1 1 auto;display:flex;align-items:center;padding:2px 18px;border-radius:8px;position:relative;transition:background .18s}
.scr-row.hover{background:var(--primary-50)}
.scr-blocks{display:flex;flex-wrap:wrap;align-items:center;min-width:0}
.scr-block{position:relative;display:flex;align-items:center;gap:10px;padding:12px 14px 12px 20px;color:#fff;font-weight:700;font-size:13px;user-select:none;margin-right:0;
border-radius:11px 11px 7px 7px;box-shadow:0 6px 0 rgba(0,0,0,.14),inset 0 2px 0 rgba(255,255,255,.30),inset 0 -5px 0 rgba(0,0,0,.08);transition:opacity .15s}
.scr-block::before{content:'';position:absolute;left:-7px;top:50%;transform:translateY(-50%);width:7px;height:30px;border-radius:7px 0 0 7px;background:inherit;z-index:2}
@ -451,7 +429,6 @@
.scr-block .scr-x{position:absolute;top:-6px;right:-6px;cursor:pointer;font-size:13px;line-height:1;padding:3px 5px;border-radius:50%;
color:rgba(255,255,255,.9);background:rgba(0,0,0,.22);z-index:3}
.scr-block .scr-x:hover{background:rgba(219,54,148,.85);color:#fff}
.m-model-row{display:flex;gap:8px;align-items:center}
.m-id{margin:0 0 8px}
.m-kind{flex:0 0 auto;margin:0 0 8px}
.donut{position:relative;width:108px;height:108px}
@ -729,14 +706,12 @@
zh: {
tagline: "统一 LLM 网关",
logout: "退出登录",
langTo: "EN",
navStatus: "状态",
navChat: "对话",
navSources: "源",
navAdapters: "适配器",
navSort: "优先级",
navKeys: "密钥",
keysTitle: "密钥管理",
keysHint:
"管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。",
kCreate: "新建密钥",
@ -747,30 +722,18 @@
kNote: "备注",
kCreateBtn: "创建",
kKey: "密钥",
kModels: "可用模型",
kCreated: "创建时间",
kActions: "操作",
kCopy: "复制密钥",
kDel: "删除",
kNewKey: "新密钥",
kNewOK: "密钥已创建",
kScope: "配置模型范围",
kScopeTitle:
"配置模型范围 —— 拖拽排列、下拉添加、×删除、复制/粘贴到其它用户",
kScopeHint: "拖拽排序 · 下拉添加 · × 删除 · 复制/粘贴列表",
kScopeSel: "选择模型…",
kCopyList: "复制列表",
kPasteList: "粘贴列表",
kSaveScope: "保存",
kScopeSaved: "模型范围已保存",
kScopeEmpty: "全部模型",
kScopeAll: "空即不限",
kMeTitle: "我的密钥",
kMeRole: "角色",
kMeModels: "我可用的模型",
kMeHint: "密钥不可在此新建或删除;需要变更请联系管理员。",
kEmpty: "暂无其他密钥",
kDelSelf: "不能删除当前登录所用密钥",
kDelConfirm: "确定删除密钥 %s 吗?此后该密钥立即失效。",
kAll: "不限",
kBrickH: "点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动",
@ -805,7 +768,6 @@
copyCfg: "一键复制配置",
copyEnv: "复制为环境变量",
srcTitle: "源状态",
srcCount: "共 %d 个",
tName: "名称",
tAdapter: "适配器",
tModels: "模型",
@ -816,13 +778,12 @@
offline: "退避 / 不可用",
adTitle: "已加载适配器",
tVersion: "版本",
chatTitle: "Chat 测试",
cModel: "模型",
cMsg: "Enter 发送Shift+Enter 换行",
send: "发送",
cClear: "清空",
clear: "清空",
cThinking: "推理",
cImg: "图片",
cPick: "选择图片",
cRemoveImg: "移除",
cWelcome: "开始对话——选择模型,输入消息即可测试上游连接",
@ -884,20 +845,15 @@
sortTitle: "拖拽积木配置模型优先级",
sortHint:
"每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位或调整同档顺序,拖到行与行之间的缝隙 = 提升或降低到新档位。生图模型不参与排序。",
sortDragGrip: "拖拽前须按住把手",
sortSave: "保存排序",
sortReset: "重置",
sortAdd: "添加档位",
sortSaved: "排序已保存并热重载",
sortNoChange: "无变更",
sortHintSave: "点击保存排序后生效",
sortCooling: "冷却",
sortFail: "失败",
sortHealthTip: "冷却 / 失败次数 / 偏好分 实时状态",
sortHealthReset: "链上冷却已复位",
sortSource: "源",
sortPrio: "优先级 %s",
sortEmpty: "该源暂无模型",
kpiActive: "活跃请求",
kpiReqs: "总请求",
kpiOk: "成功率",
@ -919,7 +875,6 @@
expKeysCsv: "导出密钥用量",
exitKeyView: "退出该密钥视图",
dashStatus: "状态码分布",
thCode: "状态码",
kOther: "其他",
statusTag:
"状态码分类统计(含 402 欠费 / 400 schema 错误;两者不计入上游退避但单独计数)",
@ -945,14 +900,12 @@
en: {
tagline: "Unified LLM Gateway",
logout: "Log out",
langTo: "中",
navStatus: "Status",
navChat: "Chat",
navSources: "Sources",
navAdapters: "Adapters",
navSort: "Priority",
navKeys: "Keys",
keysTitle: "Key management",
keysHint:
"Admin keys can view and manage every key and configure each user key\u0027s allowed models; user keys only see themselves.",
kCreate: "Create key",
@ -963,32 +916,19 @@
kNote: "Note",
kCreateBtn: "Create",
kKey: "Key",
kModels: "Allowed models",
kCreated: "Created",
kActions: "Actions",
kCopy: "Copy key",
kDel: "Delete",
kNewKey: "New key — copy & save it now",
kNewOK: "Key created",
kScope: "Model scope",
kScopeTitle:
"Model scope — drag to order, add from dropdown, × to remove, copy/paste the list to other users",
kScopeHint:
"drag to order · add from dropdown · × to remove · copy/paste list",
kScopeSel: "Pick a model…",
kCopyList: "Copy list",
kPasteList: "Paste list",
kSaveScope: "Save",
kScopeSaved: "Model scope saved",
kScopeEmpty: "all models",
kScopeAll: "empty = unlimited",
kMeTitle: "My key",
kMeRole: "Role",
kMeModels: "Models I can use",
kMeHint:
"Keys cannot be created or deleted here; contact an admin to change.",
kEmpty: "No other keys",
kDelSelf: "cannot delete the key you are logged in with",
kDelConfirm: 'Delete key "%s"? It will stop working immediately.',
kAll: "All",
kBrickH:
@ -1025,7 +965,6 @@
copyCfg: "Copy config",
copyEnv: "Copy as env vars",
srcTitle: "Sources",
srcCount: "%d total",
tName: "Name",
tAdapter: "Adapter",
tModels: "Models",
@ -1036,13 +975,12 @@
offline: "backoff / down",
adTitle: "Loaded adapters",
tVersion: "Version",
chatTitle: "Chat",
cModel: "Model",
cMsg: "Enter to send, Shift+Enter for newline",
send: "Send",
cClear: "Clear",
clear: "Clear",
cThinking: "Thinking",
cImg: "Image",
cPick: "Pick image",
cRemoveImg: "Remove",
cWelcome:
@ -1105,20 +1043,15 @@
sortTitle: "Drag blocks to set model priority",
sortHint:
"Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier or reorder within it, drop into the gap between rows = move up/down a tier. Image models stay out.",
sortDragGrip: "grab the handle to drag",
sortSave: "Save order",
sortReset: "Reset",
sortAdd: "Add slot",
sortSaved: "Order saved & hot-reloaded",
sortNoChange: "No changes",
sortHintSave: "Click Save for it to take effect",
sortCooling: "cooling",
sortFail: "fail",
sortHealthTip: "live cooldown / failures / preference score",
sortHealthReset: "chain cooldowns reset",
sortSource: "source",
sortPrio: "priority %s",
sortEmpty: "no models in this source",
kpiActive: "Active requests",
kpiReqs: "Requests",
kpiOk: "Success rate",
@ -1140,7 +1073,6 @@
expKeysCsv: "Export key usage",
exitKeyView: "Exit this key view",
dashStatus: "Status codes",
thCode: "Code",
kOther: "other",
statusTag:
"Per-status aggregates — 402 quota / 400 schema errors are counted here but never back off the provider",
@ -1191,10 +1123,8 @@
if (ll) ll.textContent = LANG === "zh" ? "EN" : "中";
document.documentElement.lang = LANG;
}
let lastTab = "status";
function activeTab() {
return lastTab;
}
/* ---------- theme & header ---------- */
/* ---------- chrome: theme / lang / sidebar / breadcrumb ---------- */
@ -1590,6 +1520,22 @@
const w = $("#modal-wrap");
if (w) w.remove();
}
// downloadStatsCsvFromForm reads the custom date range from the export
// modal's date inputs and forwards to downloadStatsCsv. (Previously this
// was referenced by the modal button but never defined — clicking
// "download" did nothing.)
function downloadStatsCsvFromForm() {
const f = $("#exp-from");
const t = $("#exp-to");
if (!f || !t) return;
// date inputs give YYYY-MM-DD; interpret as local days, end exclusive
const from = new Date(f.value + "T00:00:00").getTime();
const to = t.value
? new Date(t.value + "T00:00:00").getTime() + 864e5
: Date.now();
if (!isFinite(from) || from <= 0) return toast(t("expRange"));
downloadStatsCsv(from, to);
}
function downloadKeysCsv() {
location.href =
"/api/stats?export=keys-csv" +
@ -2113,6 +2059,7 @@
</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 ghost" id="ct-clear" title="${escAttr(t("cClear"))}" onclick="clearChat()">${esc(t("cClear"))}</button>
<button class="sendbtn" id="ct-send" onclick="sendChat()">${t("send")}</button>
</div>
</div>
@ -2302,7 +2249,6 @@
firstChunk = Date.now();
try {
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
@ -2551,7 +2497,6 @@
btn.disabled = true;
try {
await api("/api/sources", {
method: "POST",
body: JSON.stringify(payload),
});
toast(t("toastSaved"));
@ -2567,7 +2512,6 @@
async function delSource(name) {
if (!confirm(tFmt("confirmDelSrc", name))) return;
await api("/api/sources/" + encodeURIComponent(name), {
method: "DELETE",
});
toast(t("toastDelOk"));
renderSources();
@ -2722,9 +2666,6 @@
<span class="scr-grip"><i></i><i></i><i></i></span>
</div>`;
}
function findSlots(li, ji) {
return sortState.lanes[li].models[ji];
}
function paintSort(affected) {
const cv = $("#scr-canvas");
if (!cv) return;
@ -3154,7 +3095,6 @@
}),
);
await api("/api/auto", {
method: "PUT",
body: JSON.stringify({ rules }),
});
}
@ -3413,7 +3353,6 @@
if (!name || !code) return toast(t("toastNeedAll"));
try {
await api("/api/adapters", {
method: "POST",
body: JSON.stringify({ name, code }),
});
toast(t("toastUploaded"));
@ -3427,7 +3366,6 @@
async function delAdapter(name) {
if (!confirm(tFmt("confirmDelAdp", name))) return;
await api("/api/adapters/" + encodeURIComponent(name), {
method: "DELETE",
});
toast(t("toastDelOk"));
renderAdapters();
@ -3590,10 +3528,6 @@
const p = String(comb).split("|");
return { model: p[0], source: normSrc(p[1]) };
}
function scopeUncomb(comb) {
const p = String(comb).split("|");
return { model: p[0], source: p[1] || "" };
}
function fmtQuota(q) {
q = +q || 0;
if (!q) return "∞";
@ -3631,7 +3565,6 @@
}
async function putScope(key, scopes) {
await api("/api/keys/" + encodeURIComponent(key), {
method: "PUT",
body: JSON.stringify({ models: scopes }),
});
}
@ -3953,7 +3886,6 @@
let j;
try {
j = await api("/api/keys", {
method: "POST",
body: JSON.stringify({
name,
role: $("#kc-role").value,
@ -3983,7 +3915,6 @@
if (!confirm(tFmt("kDelConfirm", name || key))) return;
try {
await api("/api/keys/" + encodeURIComponent(key), {
method: "DELETE",
});
toast(t("toastDelOk"));
loadKeys();