Files
ModelRouter/cmd/gui/renderer/app.js
JianFeeeee ed9baae8e4 feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面
桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的
插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一
能用的三个操作。

补齐(复用后端已有契约,未新增端点):
- POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验
  名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默
  什么都没存。
- GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。
- DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。
- 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个
  保存后被丢弃的文本框比直接说明更糟。
- 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。

后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List
里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的
合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种
写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。
变异去掉赋值后两条判据都失败。

CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、
热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误
被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-03 08:48:46 +08:00

557 lines
19 KiB
JavaScript

// ModelRouter Desktop GUI — renderer
// Clash-Verge-style: the content area is a full-screen iframe loading the
// real WebUI 1:1 (status / chat / keys / priority / sources / adapters).
// The shell only adds the titlebar, settings overlay and tray integration.
const state = {
core: {
running: false,
ready: false,
baseUrl: "",
port: 8787,
profileDir: "",
configFile: "",
coreExists: true,
},
settings: {
port: 8787,
autoStart: true,
silentStart: false,
minimizeToTray: true,
},
theme: localStorage.getItem("mr-theme") || "light",
loadedOnce: false, // iframe has been pointed at the web UI at least once
frameLoaded: false, // last loadFrame actually finished loading (did-finish-load)
loadTries: 0,
};
const $ = (s) => document.querySelector(s);
function toast(msg, isErr, ms) {
const t = $("#toast");
t.textContent = msg;
t.className = "show" + (isErr ? " err" : "");
clearTimeout(t._tm);
t._tm = setTimeout(() => {
t.className = "";
}, ms || 2600);
}
const frame = () => $("#webui-frame");
// ===== core status =====
async function refreshCore() {
state.core = await window.modelrouter.core.state();
renderCoreStatus();
}
function renderCoreStatus() {
const core = state.core;
const dot = $("#conn-dot");
dot.className = "status-dot" + (core.running ? " ok" : " bad");
dot.title = !core.running
? "内核未运行"
: core.ready
? "内核运行中"
: "内核启动中…";
}
function showOffline() {
frame().style.display = "none";
$("#offline").style.display = "flex";
$("#console-loading").style.display = "none";
}
function showLoading() {
frame().style.display = "none";
$("#offline").style.display = "none";
$("#console-loading").style.display = "flex";
}
// probe the core's web root directly (fetch from the renderer). Using
// fetch() instead of relying on the iframe's own load means the core's
// readiness is confirmed by an actual HTTP round-trip before we point the
// iframe at it — this is what prevents the blank-screen race where the
// frame load happens while the listener is still coming up.
function coreReachable() {
return fetch(
state.core.baseUrl || "http://127.0.0.1:" + (state.core.port || 8787),
{ method: "GET", redirect: "follow", cache: "no-store" },
)
.then(() => true)
.catch(() => false);
}
// point the iframe at the WebUI, but only after the core is reachable.
// If the load fails (core still booting / raced), we retry with backoff
// instead of giving up forever (the old code set loadedOnce blindly and
// never retried -> permanent blank screen).
let frameRetryTimer = null;
function scheduleFrameLoad() {
if (frameRetryTimer) return; // already scheduled
frameRetryTimer = setTimeout(async () => {
frameRetryTimer = null;
if (!state.core.running) return; // core stopped meanwhile
if (!(await coreReachable())) {
// core not reachable yet: wait a bit and try again
state.loadTries += 1;
showLoading();
scheduleFrameLoad();
return;
}
loadFrame(state.core.baseUrl || "http://127.0.0.1:" + state.core.port);
}, state.loadTries === 0 ? 800 : Math.min(3000, 400 * state.loadTries));
}
function loadFrame(url) {
const f = frame();
f.style.display = "block";
$("#offline").style.display = "none";
$("#console-loading").style.display = "none";
if (state.loadedOnce && f.src === url) return; // already pointing there
f.src = url;
state.loadedOnce = true;
}
async function ensureCore() {
await refreshCore();
if (state.core.running) {
scheduleFrameLoad();
} else {
state.loadedOnce = false;
showOffline();
}
}
// ===== side rail toggles =====
function renderRail() {
const auto = $("#rail-autostart-dot");
const sil = $("#rail-silent-dot");
if (auto) auto.classList.toggle("on", !!state.settings.autoStart);
if (sil) sil.classList.toggle("on", !!state.settings.silentStart);
}
async function toggleAutoStart() {
const next = !state.settings.autoStart;
state.settings.autoStart = next;
await window.modelrouter.settings.set({ autoStart: next });
renderRail();
toast(next ? "开机自启已开启" : "开机自启已关闭");
}
async function toggleSilent() {
const next = !state.settings.silentStart;
state.settings.silentStart = next;
await window.modelrouter.settings.set({ silentStart: next });
renderRail();
toast(next ? "静默启动已开启" : "静默启动已关闭");
}
// ===== settings overlay =====
async function openSettings() {
state.settings = await window.modelrouter.settings.get();
$("#set-port").value = state.settings.port ?? 8787;
$("#set-autostart").checked = !!state.settings.autoStart;
$("#set-silent").checked = !!state.settings.silentStart;
$("#set-tray").checked = !!state.settings.minimizeToTray;
$("#settings-overlay").style.display = "flex";
renderRail();
loadPlugins();
}
function closeSettings() {
$("#settings-overlay").style.display = "none";
}
async function saveSettings() {
const port = parseInt($("#set-port").value, 10);
const autoStart = $("#set-autostart").checked;
const silentStart = $("#set-silent").checked;
const minimizeToTray = $("#set-tray").checked;
await window.modelrouter.settings.set({
port,
autoStart,
silentStart,
minimizeToTray,
});
closeSettings();
toast("设置已保存");
await refreshCore();
if (state.core.running) {
scheduleFrameLoad();
} else {
state.loadedOnce = false;
showOffline();
}
}
// esc / escAttr escape text for innerHTML. The WebUI has its own copies; the
// shell needs its own because renderer/app.js is a separate document that
// never loads index.html's script.
function esc(s) {
return String(s == null ? "" : s).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
}
function escAttr(s) {
return esc(s).replace(/`/g, "&#96;");
}
// ===== plugin management =====
//
// The desktop shell manages plugins through the embedded core's /api/plugins
// surface, proxied over IPC (see plugins:proxy in the main process). The
// renderer never holds the admin key.
//
// Scope note: the desktop build has no plugin_dir configured by default, so this
// panel normally reports "plugins disabled" with the one-line fix. That is a
// deliberate state, not an error — the packaged profile is a per-user directory
// and seeding a plugin tree into someone's home without asking would be rude.
async function loadPlugins() {
const list = $("#pl-list");
const hint = $("#set-plugins-hint");
if (!list || !hint) return;
let j;
try {
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
} catch (e) {
hint.textContent = "内核未就绪:" + e.message;
list.innerHTML = "";
return;
}
if (!j.plugin_dir) {
hint.innerHTML =
'未配置 <code>plugin_dir</code>,插件功能未启用。在 config.yaml 加一行后重启内核即可。';
list.innerHTML = "";
return;
}
const rows = j.on_disk || [];
window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
const active = rows.filter((p) => p.loaded && !p.disabled).length;
const broken = rows.filter((p) => !p.loaded).length;
hint.textContent =
`${rows.length} 个插件 · ${active} 个启用中` +
(broken ? ` · ${broken} 个加载失败` : "");
list.innerHTML = rows.length
? rows
.map((p) => {
const cls = !p.loaded ? "pl-broken" : p.disabled ? "pl-off" : "pl-on";
const label = !p.loaded
? "加载失败"
: p.disabled
? "已禁用"
: "启用中";
const btn = p.loaded
? `<button class="ghost" data-act="toggle" data-name="${escAttr(
p.name,
)}" data-en="${p.disabled ? "1" : "0"}">${
p.disabled ? "启用" : "禁用"
}</button>`
: "";
const builtin = p.builtin
? '<span class="pl-builtin">内置</span>'
: "";
// Edit / view source / delete are the operations an operator
// actually needs after enable/disable: a plugin that cannot be
// read back cannot be fixed, and one that cannot be removed cannot
// be rolled back.
const acts = [
btn,
`<button class="ghost" data-act="code" data-name="${escAttr(p.name)}">${
p.builtin ? "查看源码" : "编辑"
}</button>`,
builtin
? ""
: `<button class="ghost danger" data-act="del" data-name="${escAttr(
p.name,
)}">删除</button>`,
]
.filter(Boolean)
.join("");
const pages = Array.isArray(p.pages) ? p.pages : [];
return `<div class="pl-item ${cls}">
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
${pages.length ? `<div class="pl-desc">页面:${esc(pages.join(", "))}</div>` : ""}
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
<div class="pl-acts">${acts}</div>
</div>`;
})
.join("")
: '<div class="pl-empty">插件目录为空</div>';
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
});
list.querySelectorAll('button[data-act="code"]').forEach((b) => {
b.onclick = () => editPlugin(b.dataset.name);
});
list.querySelectorAll('button[data-act="del"]').forEach((b) => {
b.onclick = () => deletePlugin(b.dataset.name);
});
}
// editPlugin shows the plugin source in a modal. Built-in plugins are
// read-only: their file ships inside the binary, so a save would either be
// ignored or silently do nothing — showing an editable text area that
// discards on save is worse than saying so up front.
function editPlugin(name) {
// The plugin list is the only place `builtin` is reported; keep the last
// fetch so the modal does not need a second round trip (and cannot guess).
const row = (window.__plRows || []).find((p) => p.name === name);
const isBuiltin = !!(row && row.builtin);
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>${esc(name)}${isBuiltin ? "(内置,只读)" : ""}</h2></div>
${isBuiltin
? `<p class="hint">内置插件随内核发布,源码在二进制内,不可编辑。</p>`
: `<textarea id="pl-code" spellcheck="false"></textarea>
<p class="hint" id="pl-code-err"></p>`}
<div class="row actions">
${isBuiltin ? "" : `<button id="pl-code-save">保存并热重载</button>`}
<button class="ghost" id="pl-code-close">关闭</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-code-close").onclick = () => wrap.remove();
const save = $("#pl-code-save");
if (save) {
save.onclick = async () => {
save.disabled = true;
try {
// Saving writes through POST /api/plugins — the same install path the
// installer uses, which validates the name, compiles the source and
// hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
// save there would silently disable nothing and store nothing.
await window.modelrouter.plugins.request("POST", "/api/plugins", {
name,
code: $("#pl-code").value,
});
toast(`${name} 已保存`);
wrap.remove();
await loadPlugins();
} catch (e) {
$("#pl-code-err").textContent = e.message;
save.disabled = false;
}
};
}
if (isBuiltin) return;
(async () => {
const ta = $("#pl-code");
try {
const j = await window.modelrouter.plugins.request(
"GET",
`/api/plugins/${encodeURIComponent(name)}`,
);
ta.value = j.code || "";
const err = $("#pl-code-err");
if (err && j.parse_error) err.textContent = String(j.parse_error);
} catch (e) {
$("#pl-code-err").textContent = e.message;
}
})();
}
function deletePlugin(name) {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card">
<div class="overlay-head"><h2>删除插件</h2></div>
<p>确定删除 <b>${esc(name)}</b>?插件目录中的文件会被移除,核心将不再加载它。</p>
<div class="row actions">
<button class="danger" id="pl-del-ok">删除</button>
<button class="ghost" id="pl-del-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-del-no").onclick = () => wrap.remove();
$("#pl-del-ok").onclick = async () => {
try {
await window.modelrouter.plugins.request(
"DELETE",
`/api/plugins/${encodeURIComponent(name)}`,
);
toast(`已删除 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
toast(e.message, true);
}
};
}
// installPlugin creates a new plugin from source. The name becomes the file
// name, so it is validated client-side to save a round trip on the obvious
// mistakes (empty, path separators, non-portable characters).
function installPlugin() {
const wrap = document.createElement("div");
wrap.className = "overlay";
wrap.innerHTML = `<div class="overlay-card pl-wide">
<div class="overlay-head"><h2>安装插件</h2></div>
<div class="row"><label>名称</label><input id="pl-new-name" placeholder="my_plugin" /></div>
<p class="hint" id="pl-new-err"></p>
<textarea id="pl-new-code" spellcheck="false" placeholder="-- 单文件 Lua 插件&#10;plugin = {}&#10;..."></textarea>
<div class="row actions">
<button id="pl-new-ok">安装</button>
<button class="ghost" id="pl-new-no">取消</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.addEventListener("click", (e) => {
if (e.target === wrap) wrap.remove();
});
$("#pl-new-no").onclick = () => wrap.remove();
$("#pl-new-ok").onclick = async () => {
const name = $("#pl-new-name").value.trim();
const code = $("#pl-new-code").value;
const err = $("#pl-new-err");
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
err.textContent = "名称只能包含字母、数字、下划线和连字符";
return;
}
if (!code.trim()) {
err.textContent = "源码不能为空";
return;
}
$("#pl-new-ok").disabled = true;
try {
await window.modelrouter.plugins.request("POST", "/api/plugins", {
// The backend appends .lua itself (installPlugin writes name+".lua"),
// so passing it here would create name.lua.lua.
name,
code,
});
toast(`已安装 ${name}`);
wrap.remove();
await loadPlugins();
} catch (e) {
err.textContent = e.message;
$("#pl-new-ok").disabled = false;
}
};
}
async function togglePlugin(name, disabled) {
try {
await window.modelrouter.plugins.request("PUT", `/api/plugins/${encodeURIComponent(name)}`, {
enabled: disabled,
});
toast(disabled ? `已禁用 ${name}` : `已启用 ${name}`);
await loadPlugins();
} catch (e) {
toast(e.message, true);
}
}
async function enableAllPlugins() {
let j;
try {
j = await window.modelrouter.plugins.request("GET", "/api/plugins");
} catch (e) {
return toast(e.message, true);
}
const off = (j.on_disk || []).filter((p) => p.loaded && p.disabled);
for (const p of off) {
try {
await window.modelrouter.plugins.request(
"PUT",
`/api/plugins/${encodeURIComponent(p.name)}`,
{ enabled: true },
);
} catch (e) {
toast(`${p.name}: ${e.message}`, true);
}
}
toast(off.length ? `已启用 ${off.length} 个插件` : "没有处于禁用状态的插件");
await loadPlugins();
}
// ===== theme =====
function applyTheme() {
document.documentElement.dataset.theme = state.theme;
try {
localStorage.setItem("mr-theme", state.theme);
} catch (e) {}
}
// ===== init =====
function init() {
$("#tb-min").onclick = () => window.modelrouter.win.minimize();
$("#tb-max").onclick = () => window.modelrouter.win.toggleMaximize();
$("#tb-close").onclick = () => window.modelrouter.win.close();
$("#tb-settings").onclick = openSettings;
$("#rail-settings").onclick = openSettings;
const plReload = document.getElementById("pl-reload");
if (plReload) plReload.onclick = loadPlugins;
const plAll = document.getElementById("pl-toggle-all");
if (plAll) plAll.onclick = enableAllPlugins;
const plInstall = document.getElementById("pl-install");
if (plInstall) plInstall.onclick = installPlugin;
$("#rail-autostart").onclick = toggleAutoStart;
$("#rail-silent").onclick = toggleSilent;
$("#rail-theme").onclick = () => {
state.theme = state.theme === "dark" ? "light" : "dark";
applyTheme();
};
$("#set-close").onclick = closeSettings;
$("#set-cancel").onclick = closeSettings;
$("#set-save").onclick = saveSettings;
$("#set-dir").onclick = () =>
window.modelrouter.shell.openPath(state.core.profileDir);
$("#set-log").onclick = async () => {
const p = await window.modelrouter.settings.logFile();
window.modelrouter.shell.openPath(p);
};
$("#off-start").onclick = async () => {
showLoading();
await window.modelrouter.core.start();
await refreshCore();
if (state.core.running) scheduleFrameLoad();
else showOffline();
};
// when the iframe finishes loading, mark loaded and refresh core status so
// the conn-dot reflects the actual WebUI (not just the core reachability)
frame().onload = () => {
state.frameLoaded = true;
refreshCore();
};
// load failure (e.g. the frame was pointed at the core just before it
// finished booting) -> clear loadedOnce and let scheduleFrameLoad retry
frame().onerror = () => {
state.loadedOnce = false;
state.frameLoaded = false;
scheduleFrameLoad();
};
window.modelrouter.core.onState((d) => {
state.core = Object.assign({}, state.core, d);
renderCoreStatus();
if (d.running && !state.loadedOnce) scheduleFrameLoad();
if (!d.running) {
state.loadedOnce = false;
showOffline();
}
});
// Escape closes overlay
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("#settings-overlay").style.display === "flex")
closeSettings();
});
}
async function boot() {
applyTheme();
init();
state.settings = await window.modelrouter.settings.get();
renderRail();
await ensureCore();
}
boot();