Files
ModelRouter/cmd/gui/renderer/app.js
root 97e4ab582d docs: 中性化 README 与代码注释中的第三方客户端引用
- README.md / README_EN.md:桌面 GUI 段落的第三方客户端类比改为中性的「托盘式桌面客户端」,功能说明不变
- cmd/gui 注释、internal/provider 注释:代理示例改为中性的本地 HTTP 代理表述
- internal/lua/plugins/channels.lua、internal/lua/vm.go:注释措辞中性化

仅文案与注释调整,无任何逻辑变更。
2026-10-26 21:00:24 +08:00

557 lines
19 KiB
JavaScript

// ModelRouter Desktop GUI — renderer
// Tray-app 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();