mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
- provider.truncate: zero callers (oneLineStr is the used superset) - gateway.normalizeModel: zero callers - config.resolvedAPIKey: zero callers (core.resolveSourceKey is the live equivalent) - Stats.Records: zero callers (CSV export uses AuditRecords) - store.containsString: zero callers - Config.MaxConcurrent: global inflight-cap field never read; per-source MaxConcurrent is what actually drives semaphores. Legacy configs carrying a top-level max_concurrent key still load (yaml.v3 ignores unknown fields — verified by test). - gui renderer esc(): zero callers; renderer uses textContent, and the embedded WebUI has its own esc()
260 lines
7.8 KiB
JavaScript
260 lines
7.8 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();
|
|
}
|
|
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();
|