// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
);
}
function escAttr(s) {
return esc(s).replace(/`/g, "`");
}
// ===== 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 =
'未配置 plugin_dir,插件功能未启用。在 config.yaml 加一行后重启内核即可。';
list.innerHTML = "";
return;
}
const rows = j.on_disk || [];
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
? ``
: "";
const builtin = p.builtin
? '内置'
: "";
return `