// 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(); } 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(); } } // ===== 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; $("#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();