mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
feat(gui): Electron desktop with embedded core, tray, autostart, win/linux packaging
- cmd/gui: Electron shell (Clash-Verge style) embedding the full WebUI 1:1 - embedded llmsproxy core (luajit) with auto-generated profile - key stored in keys[] (non-seed) so no replace-the-key warning - gw_key cookie injection: web UI works without login - side-rail toggles for autostart / silent start - system tray with status + controls, silent start (--silent) - win cross-build (mingw luajit exe + dll) / deb / AppImage via electron-builder - Makefile: build / gui / gui-dist / gui-deb / gui-win targets - README: desktop GUI section - lua(adapter): opencode normalizes non-whitelisted roles to system
This commit is contained in:
198
cmd/gui/renderer/app.js
Normal file
198
cmd/gui/renderer/app.js
Normal file
@ -0,0 +1,198 @@
|
||||
// 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,
|
||||
baseUrl: "",
|
||||
port: 8787,
|
||||
profileDir: "",
|
||||
configFile: "",
|
||||
coreExists: true,
|
||||
},
|
||||
settings: {
|
||||
port: 8787,
|
||||
autoStart: true,
|
||||
silentStart: false,
|
||||
minimizeToTray: true,
|
||||
},
|
||||
theme: localStorage.getItem("mr-theme") || "light",
|
||||
loadedOnce: false,
|
||||
};
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
||||
c
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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 ? "内核运行中" : "内核未运行";
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
function loadFrame(url) {
|
||||
const f = frame();
|
||||
f.style.display = "block";
|
||||
$("#offline").style.display = "none";
|
||||
$("#console-loading").style.display = "none";
|
||||
f.src = url;
|
||||
state.loadedOnce = true;
|
||||
}
|
||||
|
||||
async function ensureCore() {
|
||||
await refreshCore();
|
||||
if (state.core.running) {
|
||||
loadFrame(state.core.baseUrl);
|
||||
} else {
|
||||
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) {
|
||||
loadFrame(state.core.baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 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) loadFrame(state.core.baseUrl);
|
||||
else showOffline();
|
||||
};
|
||||
frame().onload = () => refreshCore();
|
||||
window.modelrouter.core.onState((d) => {
|
||||
state.core = Object.assign({}, state.core, d);
|
||||
renderCoreStatus();
|
||||
if (d.running && !state.loadedOnce) loadFrame(state.core.baseUrl);
|
||||
if (!d.running) showOffline();
|
||||
});
|
||||
// Escape closes overlay
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && $("#settings-overlay").style.display === "flex")
|
||||
closeSettings();
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
init();
|
||||
state.settings = await window.modelrouter.settings.get();
|
||||
renderRail();
|
||||
await ensureCore();
|
||||
}
|
||||
|
||||
boot();
|
||||
Reference in New Issue
Block a user