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:
2026-08-16 09:53:05 +08:00
parent 40e08b14d2
commit 47f3b44d92
16 changed files with 6102 additions and 2 deletions

9
.gitignore vendored
View File

@ -5,3 +5,12 @@
/runtime.json
/master.key
*.logmaster.key
# GUI (Electron)
cmd/gui/node_modules/
cmd/gui/bin/
cmd/gui/build/gui-dist/
cmd/build/
cmd/gui/*.log
# workspace
.pi/
/build/

57
Makefile Normal file
View File

@ -0,0 +1,57 @@
.PHONY: all build test gui gui-dev gui-dist gui-deb gui-win help clean
# ModelRouter - single-binary gateway + optional Electron desktop GUI (embedded core)
export BINARY
BINARY=llmsproxy
export BUILD_DIR
BUILD_DIR=build
export GUI_DIR
GUI_DIR=cmd/gui
VERSION ?= $(shell git describe --tags --dirty 2>/dev/null || echo "0.1.0")
all: build
# ---- core binary (server users / embedded core) ----
build:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 go build -tags luajit -trimpath -o $(BUILD_DIR)/$(BINARY) ./cmd/llmsproxy
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
# ---- GUI (Electron) ----
gui:
@cd $(GUI_DIR) && npm start
gui-dev:
@cd $(GUI_DIR) && npm run dev
# build the embedded core into the GUI, then run electron-builder
# targets: deb + AppImage (linux), rpm needs system rpmbuild
gui-dist:
@cd $(GUI_DIR) && npm run dist:linux
# deb only (for local sharing), rpm needs rpmbuild
gui-deb:
@cd $(GUI_DIR) && npm run dist:debian
# windows nsis installer (needs apt install gcc-mingw-w64-x86-64 + wine for electron-builder)
gui-win:
@cd $(GUI_DIR) && npm run dist:win
gui-dist-dir:
@cd $(GUI_DIR) && npm run dist:dir
test:
go test ./...
clean:
rm -rf $(BUILD_DIR) cmd/gui/build/gui-dist cmd/gui/bin
help:
@echo "Targets:"
@echo " make build build core binary (luajit)"
@echo " make gui run desktop GUI (dev)"
@echo " make gui-dev run GUI with devtools"
@echo " make gui-dist build deb + AppImage (linux)"
@echo " make gui-deb build deb only"
@echo " make gui-win build Windows nsis (needs mingw + wine)"
@echo " make test run go tests"

View File

@ -16,12 +16,14 @@
## 核心优势
### 极致轻量
- **单二进制**:编译后约 10MB零运行时依赖仅依赖系统 libc部署即用
- **极低内存占用**:空闲状态仅 ~15MB RSS满载并发 100+ 请求时峰值 < 100MB
- **零运行时依赖** Go + LuaJIT 静态链接无需安装 Python/Node/Java 等运行时
- **启动极快**冷启动 < 200ms热重载配置 < 10ms
### 强大的多租户调度能力
- **多密钥多租户**支持无限密钥每个密钥独立角色模型范围Token 配额重置周期
- **AUTO 智能调度**基于优先级档位的分级调度同优先级源自动轮询负载均衡故障自动毫秒级故障转移
- **Token 配额管理**精确到模型级别的 Token 配额控制支持小时///自定义小时周期自动重置
@ -29,6 +31,7 @@
- **Source-Model 前缀路由**支持 `source-model`/`source:model`/`source/model` 精确指定上游源
### 生产级可靠性
- **热加载配置**WebUI 修改源/密钥/适配器/AUTO链即时生效无需重启
- **密钥加密落盘**AES-256-GCM 加密存储 `api_key`网关密钥请求头master.key 0600 权限保护
- **实时源探测**`GET {base}/models` 定期探测毫秒级感知上游状态不污染调度退避状态
@ -36,6 +39,7 @@
- **AES-256-GCM 加密存储**运行时文件敏感字段加密落盘master.key 0600 权限支持环境变量注入主密钥
### 灵活的协议适配
- **LuaJIT VM**每个适配器独立 VM + worker 安全并发Lua 脚本热加载无需重启
- **Lua 适配器协议**`transform_request` / `transform_response` / `transform_stream_chunk` 双向转换
- **动态请求头钩子**`build_headers(meta)` 支持 HMAC 签名动态 Header 注入
@ -297,10 +301,41 @@ zen 按 User-Agent 指纹识别官方客户端并分流——非官方 UA 的请
`meta.app_secret` 对时间戳+URL+请求体哈希做 HMAC 签名并附 `X-App-Sign` 等头。
配好 `sources[].meta.{app_id, app_secret, app_agent}` 即可。
## 桌面 GUIElectron
可选独立产物,面向内网桌面端用户:**内嵌 ModelRouter 核心**Clash Verge 形态),
一键启停、系统托盘常驻、开机自启、静默启动,内容区一比一内嵌完整 WebUI
(状态/对话/密钥/优先级/源/适配器 六个页面,免登录)。服务器用户继续使用纯 Go 二进制。
### 构建与运行
```bash
cd cmd/gui
npm install
make gui # 或 npm run dev —— 开发运行(需先 make build 生成 bin/llmsproxy
make gui-deb # 本机构建 deb分享给其它 Linux 用户)
make gui-dist # 构建 deb + AppImage
make gui-win # 构建 Windows nsis 安装包(需先 apt install gcc-mingw-w64-x86-64electron-builder 用 wine
```
产物在 `cmd/build/gui-dist/``ModelRouter-1.0.0.AppImage``modelrouter-gui_1.0.0_amd64.deb`
`modelrouter-gui Setup 1.0.0.exe`win需 mingw 交叉编译核心后。rpm 需系统 `rpmbuild`
### 特性
- **内嵌核心**:自动拉起捆绑的 `llmsproxy`LuaJIT 版),配置/密钥/adapter 存于
用户数据目录 `<userData>/profile/`,首次运行自动生成随机 admin key 写入 `keys`
(非 seed并自注入WebUI 免登录、不会提示"更换初始密钥"。端口可在设置中更改(默认 8787
- **系统托盘**:内核状态点、开机自启/静默启动开关、启停/重启内核、退出。
- **静默启动**`--silent` 参数或设置项,启动仅驻托盘不弹窗;首次运行始终显示窗口。
- **开机自启**Windows/macOS 用 `setLoginItemSettings`Linux 写
`~/.config/autostart/modelrouter-gui.desktop`,静默开启时自动附加 `--silent`
## 目录
```
cmd/llmsproxy # 入口
cmd/llmsproxy # 入口(纯 Go 服务端)
cmd/gui # Electron 桌面 GUI内嵌核心 + 托盘 + 打包)
internal/config # YAML 配置加载/校验
internal/lua # LuaJIT VM + worker 池 + AdapterCache + 内置适配器 (embed)
internal/provider # Provider(HTTP) + Registry(路由)
@ -315,4 +350,4 @@ go test -tags luajit ./...
```
覆盖:配置校验、适配器加载/变换、签名钩子、Gateway 鉴权、SDK 结算、SSE 流式、
模型路由、多模态透传与 disable_thinking。
模型路由、多模态透传与 disable_thinking。

BIN
cmd/gui/build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
cmd/gui/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

11
cmd/gui/icon.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 34" width="34" height="34">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3f6ef5"/>
<stop offset="1" stop-color="#6a8ffb"/>
</linearGradient>
</defs>
<rect width="34" height="34" rx="10" fill="url(#g)"/>
<text x="17" y="24" text-anchor="middle" font-family="Arial,sans-serif" font-size="17" font-weight="700" fill="#ffffff">M</text>
</svg>

After

Width:  |  Height:  |  Size: 506 B

663
cmd/gui/main.js Normal file
View File

@ -0,0 +1,663 @@
// ModelRouter Desktop GUI — main process
// Clash-Verge-style: bundles and manages an embedded llmsproxy core instance
// in a per-user profile dir (userData), auto-starts it, keeps a tray icon,
// supports launch-at-login. Server-side users keep using the pure Go binary.
const {
app,
BrowserWindow,
ipcMain,
Menu,
Tray,
shell,
nativeImage,
} = require("electron");
const path = require("path");
const fs = require("fs");
const http = require("http");
const os = require("os");
const crypto = require("crypto");
const { spawn } = require("child_process");
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
function log(msg) {
try {
fs.appendFileSync(LOG_FILE, new Date().toISOString() + " " + msg + "\n");
} catch (e) {}
}
const _consoleLog = console.log,
_consoleErr = console.error;
console.log = function () {
log(Array.prototype.slice.call(arguments).join(" "));
_consoleLog.apply(null, arguments);
};
console.error = function () {
log("ERR " + Array.prototype.slice.call(arguments).join(" "));
_consoleErr.apply(null, arguments);
};
// ---------- embedded core profile ----------
// Per-user profile dir: <userData>/profile
const PROFILE_DIR = path.join(app.getPath("userData"), "profile");
const CONFIG_FILE = path.join(PROFILE_DIR, "config.yaml");
// Packaged: extraResource at process.resourcesPath/bin (outside asar, spawnable).
// Dev: cmd/gui/bin, populated by prepare-core.js.
const CORE_EXE = path.join(
app.isPackaged ? process.resourcesPath : __dirname,
"bin",
process.platform === "win32" ? "llmsproxy.exe" : "llmsproxy",
);
// default port for the embedded core (configurable via settings)
const DEFAULT_PORT = 8787;
let mainWindow = null;
let tray = null;
let coreProc = null;
let coreStartedAt = 0;
let settings = {}; // { port, autoStart, minimizeToTray }
let authRules = [];
const isDev = process.argv.includes("--dev");
const silentFlag = process.argv.includes("--silent");
function loadSettings() {
try {
const p = path.join(app.getPath("userData"), "settings.json");
if (fs.existsSync(p)) settings = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch (e) {}
if (typeof settings.port !== "number") settings.port = DEFAULT_PORT;
if (typeof settings.silentStart !== "boolean") settings.silentStart = false;
if (typeof settings.minimizeToTray !== "boolean")
settings.minimizeToTray = true;
return settings;
}
function saveSettings() {
try {
fs.writeFileSync(
path.join(app.getPath("userData"), "settings.json"),
JSON.stringify(settings, null, 2),
"utf-8",
);
} catch (e) {}
}
// read admin key from embedded config.yaml (generated by core first run)
function readAdminKey() {
// The embedded profile stores its admin key in `keys` (NOT gateway_keys):
// gateway_keys entries get marked seed:true by the core (-> "replace the
// initial key" warning in the web UI), while a plain keys entry authenticates
// without any seed warning.
try {
if (!fs.existsSync(CONFIG_FILE)) return "";
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
// keys:
// - key: sk-gw-...
const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec(
raw,
);
if (k && k[1]) return k[1];
// legacy: gateway_keys list (pre-migration configs)
const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(
raw,
);
if (g && g[1]) return g[1];
const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw);
if (g2 && g2[1]) return g2[1];
} catch (e) {}
return "";
}
const embeddedBaseUrl = () =>
"http://127.0.0.1:" + (settings.port || DEFAULT_PORT);
// ---------- embedded core lifecycle ----------
function isCoreRunning() {
return coreProc !== null && coreProc.exitCode === null;
}
function coreStarted() {
return coreStartedAt > 0 && isCoreRunning();
}
function writeDefaultEmbeddedConfig() {
// Generate a self-contained embedded profile with a fresh random admin key.
// (Like Clash Verge generates its profile; the core's EnsureDefault default
// is 127.0.0.1:8080 — we want our own port + key on first run.) If a config
// already exists we do NOT overwrite it (keep user's edits).
if (fs.existsSync(CONFIG_FILE)) return;
const key = "sk-gw-" + crypto.randomBytes(16).toString("hex");
const yaml = [
"# ModelRouter embedded profile (auto-generated)",
"listen: 127.0.0.1:" + (settings.port || DEFAULT_PORT),
"default_model: AUTO",
"adapter_dir: " + JSON.stringify(path.join(PROFILE_DIR, "adapters")),
"runtime_file: " + JSON.stringify(path.join(PROFILE_DIR, "runtime.json")),
"",
"sources:",
" - name: zen",
" base_url: https://opencode.ai/zen/v1",
" api_key: public",
" adapter: opencode",
" models:",
" - id: deepseek-v4-flash-free",
" priority: 100",
" kind: chat",
"",
// Admin key as a plain `keys` entry (no seed flag) so the web UI never
// shows the replace-the-initial-key warning. gateway_keys entries are
// marked seed:true by the core.
"keys:",
" - key: " + key,
" role: admin",
" name: embedded",
].join("\n");
try {
fs.mkdirSync(PROFILE_DIR, { recursive: true });
fs.writeFileSync(CONFIG_FILE, yaml, "utf-8");
} catch (e) {
console.error("write embedded config failed:", e);
}
}
// does the embedded core answer already? returns its admin key if so
function probeEmbedded() {
// Use the admin key from the embedded profile so an up core that requires
// auth (401 without key) is still detected as healthy. Any HTTP response
// (even 401/200) proves the listener is up; network errors mean it is not.
return new Promise((resolve) => {
const u = embeddedBaseUrl();
const key = readAdminKey();
const headers = key ? { Authorization: "Bearer " + key } : {};
const req = http.get(u + "/v1/models", { headers }, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => resolve({ ok: true, status: res.statusCode, body }));
});
req.on("error", () => resolve({ ok: false }));
req.setTimeout(1200, () => {
req.destroy();
resolve({ ok: false });
});
});
}
function startCore() {
if (coreProc && coreProc.exitCode === null) {
console.log("core already running");
return;
}
if (!fs.existsSync(CORE_EXE)) {
console.error("embedded core binary missing:", CORE_EXE);
return;
}
// ensure profile dir + config + local adapters exist
fs.mkdirSync(PROFILE_DIR, { recursive: true });
if (!fs.existsSync(CONFIG_FILE)) writeDefaultEmbeddedConfig();
coreProc = spawn(CORE_EXE, ["-config", CONFIG_FILE], {
cwd: PROFILE_DIR,
env: Object.assign({}, process.env, {
LLMS_PROXY_GUI: "1",
}),
stdio: ["ignore", "pipe", "pipe"],
});
coreStartedAt = Date.now();
// capture core logs -> gui log
coreProc.stdout.on("data", (d) => log("[core] " + String(d).trim()));
coreProc.stderr.on("data", (d) => log("[core-err] " + String(d).trim()));
coreProc.on("error", (err) => {
console.error("core spawn error:", err.message);
coreProc = null;
});
coreProc.on("exit", (code) => {
const wasManaged = coreStartedAt > 0;
coreStartedAt = 0;
coreProc = null;
console.log("core exited:", code);
// auto-restart if still running GUI (crash recovery, max every ~5s)
if (wasManaged && !app.isQuitting) {
const since = Date.now() - (coreLastExit || 0);
if (since < 2000) return; // avoid tight crash loop
coreLastExit = Date.now();
setTimeout(() => {
if (!app.isQuitting) startCore();
}, 1500);
}
});
notifyCoreState();
// pump until the core answers, then inject the admin key and tell the
// renderer the web UI is ready (the iframe must load only after auth is set)
waitForCoreReady()
.then((ok) => {
if (ok) {
setEmbeddedAuth();
notifyCoreState();
}
})
.catch(() => {});
}
// waitForCoreReady polls probeEmbedded until the listener is up (or timeout).
function waitForCoreReady(timeoutMs = 10000) {
const start = Date.now();
return new Promise((resolve) => {
const poll = () => {
probeEmbedded().then((p) => {
if (p.ok) return resolve(true);
if (Date.now() - start > timeoutMs) return resolve(false);
setTimeout(poll, 250);
});
};
poll();
});
}
let coreLastExit = 0;
function stopCore() {
app.isQuitting = true;
if (coreProc && coreProc.exitCode === null) {
try {
coreProc.kill();
} catch (e) {}
}
coreProc = null;
coreStartedAt = 0;
notifyCoreState();
}
function restartCore() {
app.isQuitting = true;
if (coreProc && coreProc.exitCode === null) {
try {
coreProc.kill();
} catch (e) {}
}
coreProc = null;
coreStartedAt = 0;
app.isQuitting = false;
setTimeout(() => startCore(), 400);
}
// ---------- auth rule injection (embedded only) ----------
function installAuthRule() {
// Read the admin key live on every request so injection never depends on
// ordering: as soon as the embedded core has written its config (key exists)
// the web UI gets authenticated, even if the renderer loaded early.
const apply = (details, callback) => {
const url = details.url || "";
const rule = authRules.find((r) => r.url && url.indexOf(r.url) === 0);
if (!rule) return callback({ requestHeaders: details.requestHeaders });
const h = Object.assign({}, details.requestHeaders);
const key = rule.apiKey || readAdminKey() || "";
// ModelRouter auth() checks gw_key cookie first; Chromium lets us inject
// Cookie on top-level nav AND cross-origin XHR/fetch, which the CORS-
// restricted Authorization header cannot do inside a sandboxed iframe.
if (key && !/(^|;)\s*gw_key=/.test(h.Cookie || ""))
h["Cookie"] = (h["Cookie"] ? h["Cookie"] + "; " : "") + "gw_key=" + key;
if (rule.cookie)
h["Cookie"] = (h["Cookie"] ? h["Cookie"] + "; " : "") + rule.cookie;
if (rule.headers)
Object.keys(rule.headers).forEach((k) => (h[k] = rule.headers[k]));
callback({ requestHeaders: h });
};
require("electron").session.defaultSession.webRequest.onBeforeSendHeaders(
(details, callback) => {
try {
apply(details, callback);
} catch (e) {
callback({ requestHeaders: details.requestHeaders });
}
},
);
require("electron").session.defaultSession.webRequest.onHeadersReceived(
(details, callback) => {
const h = Object.assign({}, details.responseHeaders || {});
const url = details.url || "";
if (authRules.some((r) => r.url && url.indexOf(r.url) === 0)) {
h["Access-Control-Allow-Origin"] = ["*"];
h["Access-Control-Allow-Methods"] = ["GET,POST,PUT,DELETE,OPTIONS"];
h["Access-Control-Allow-Headers"] = [
"Content-Type, X-API-Key, Authorization, Cookie, Range",
];
}
callback({ responseHeaders: h });
},
);
}
function setEmbeddedAuth() {
authRules = [];
authRules.push({ url: embeddedBaseUrl(), apiKey: readAdminKey() || "" });
}
// ---------- tray ----------
function createTray() {
// Tray icon: prefer the packaged asset (inside asar) or the dev build dir.
// GNOME renders tray icons via AppIndicator: a fully transparent PNG with
// only colored pixels draws as an odd box, so ship an opaque rounded-blue
// M icon with a subtle white ring for dark/light trays alike.
const candidates = [
path.join(__dirname, "build", "icon.png"),
path.join(process.resourcesPath || "", "build", "icon.png"),
];
let img = null;
for (const c of candidates) {
try {
const t = nativeImage.createFromPath(c);
if (!t.isEmpty()) {
img = t;
break;
}
} catch (e) {}
}
if (!img) {
// clear blue-M fallback (never the transparent fabric pixel)
img = nativeImage.createFromDataURL(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAACXBIWXMAAAsTAAALEwEAmpwYAAABkUlEQVQ4jb2Vz0sCQRTH/S+yP8Ydkg7RKRD6FwpzxiWoQyL+Awohab8g6RD04yBeIuhQt7pVkCeFLv4Hzdtf7vhiZnHTXVzUtR485u3j7Wdn38x8J5H4L0vpfFnLASOU1wiD02jnR4QCle9EQgkFnVBuEAY4m3PQGOQnQ9mswIBToKHfJ3PNNDxzkuVJH6x6Ghvqe+4XzHg9WJCvWli68HyjYPh5GQ/zsibcDl4bAUMjWPDWcXFo9abj52tN28+/d0UILFlTgcUA8bXt+vmXtqtyscHdnkDTRlzdBUzrgGANsNMT8cF3z301sqqJuUNTxbdP/fjgwrmlxssHBxv3jooPzqz44M2Sgdwc4OeXwI+uUK3IFI344EzRUAvmCkTH9RZSbrmFgE9aXgukHbecWcC8HgXerniLJm2rbEaCxw6IFI9gQfnaxqtHB9f3DVzRQcXS5ZZb2/OeKzd2CJyisDMuQoxDXJ3QKOdp9r00pnAag/xCBSigyVR+dZ6ZToT68CxPyiJ17UxzNcnaUQ3+a/sBfMAWejRz3/oAAAAASUVORK5CYII=",
);
}
tray = new Tray(img);
const rebuild = () => {
const running = coreStarted();
const menu = Menu.buildFromTemplate([
{
label: "ModelRouter Desktop",
enabled: false,
},
{
label: running ? "● 内核运行中" : "○ 内核已停止",
enabled: false,
},
{ type: "separator" },
{
label: "显示主窗口",
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
},
},
{ type: "separator" },
{
label: "开启内核 (核心网关启动)",
enabled: !running,
click: () => startCore(),
},
{
label: "重启内核",
enabled: running,
click: () => restartCore(),
},
{
label: "停止内核",
enabled: running,
click: () => stopCore(),
},
{ type: "separator" },
{
label: "开机自启",
type: "checkbox",
checked: !!settings.autoStart,
click: () => {
settings.autoStart = !settings.autoStart;
applyAutoStart();
saveSettings();
rebuild();
},
},
{
label: "静默启动",
type: "checkbox",
checked: !!settings.silentStart,
click: () => {
settings.silentStart = !settings.silentStart;
saveSettings();
rebuild();
},
},
{ type: "separator" },
{
label: "退出",
click: () => app.quit(),
},
]);
tray.setContextMenu(menu);
};
tray.setToolTip("ModelRouter");
tray.on("click", () => {
if (mainWindow) {
mainWindow.isVisible() ? mainWindow.focus() : mainWindow.show();
}
});
rebuild();
global.__rebuildTray = rebuild;
// keep a weak ref; main process keeps tray alive
}
function notifyCoreState() {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("core:state", {
running: coreStarted(),
startedAt: coreStartedAt,
});
}
try {
if (global.__rebuildTray) global.__rebuildTray();
} catch (e) {}
}
// ---------- auto start / launch at login ----------
function setupAutoStart() {
if (settings.autoStart === undefined) settings.autoStart = true;
applyAutoStart();
}
function applyAutoStart() {
const on = !!settings.autoStart;
if (process.platform === "linux") {
// Linux: explicit XDG autostart desktop entry (app.setLoginItemSettings
// is a no-op on Linux).
const d = path.join(
os.homedir(),
".config",
"autostart",
"modelrouter-gui.desktop",
);
try {
if (on) {
const exe = process.execPath;
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.writeFileSync(
d,
'[Desktop Entry]\nType=Application\nName=ModelRouter\nComment=Unified LLM gateway\nExec="' +
exe +
'" --no-sandbox' +
(settings.silentStart ? " --silent" : "") +
"\nTerminal=false\nX-GNOME-Autostart-enabled=true\n",
"utf-8",
);
} else if (fs.existsSync(d)) {
fs.unlinkSync(d);
}
} catch (e) {
console.warn("linux autostart failed:", e.message);
}
return;
}
try {
app.setLoginItemSettings({ openAtLogin: on });
} catch (e) {
console.warn("auto-start not supported:", e.message);
}
}
// ---------- window ----------
function createWindow() {
const menu = Menu.buildFromTemplate([]);
Menu.setApplicationMenu(menu);
mainWindow = new BrowserWindow({
width: 1280,
height: 860,
minWidth: 900,
minHeight: 600,
title: "ModelRouter",
frame: false,
show: false,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.loadFile(path.join(__dirname, "renderer", "index.html"));
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url && url.startsWith("http")) shell.openExternal(url);
return { action: "deny" };
});
// Silent start: `--silent` CLI flag or settings.silentStart keeps the app
// in the tray without showing the window (Clash-Verge style). First run
// always shows the window so the user sees the dashboard.
mainWindow.once("ready-to-show", () => {
const firstRun = !fs.existsSync(
path.join(app.getPath("userData"), "settings.json"),
);
if (firstRun || !(silentFlag || settings.silentStart)) {
mainWindow.show();
}
});
mainWindow.on("close", (e) => {
if (settings.minimizeToTray && !app.isQuitting) {
e.preventDefault();
mainWindow.hide();
tray &&
tray.displayBalloon &&
tray.displayBalloon({
title: "ModelRouter",
content: "已最小化到托盘,内核继续运行",
});
}
});
mainWindow.on("closed", () => (mainWindow = null));
if (isDev) mainWindow.webContents.openDevTools();
}
// ---------- IPC ----------
ipcMain.handle("window:minimize", (e) =>
BrowserWindow.fromWebContents(e.sender)?.minimize(),
);
ipcMain.handle("window:toggleMaximize", (e) => {
const w = BrowserWindow.fromWebContents(e.sender);
if (!w) return;
if (w.isMaximized()) w.unmaximize();
else w.maximize();
});
ipcMain.handle("window:close", (e) =>
BrowserWindow.fromWebContents(e.sender)?.close(),
);
ipcMain.handle(
"window:isMaximized",
(e) => !!BrowserWindow.fromWebContents(e.sender)?.isMaximized(),
);
ipcMain.handle("core:state", () => ({
running: coreStarted(),
startedAt: coreStartedAt,
baseUrl: embeddedBaseUrl(),
port: settings.port || DEFAULT_PORT,
profileDir: PROFILE_DIR,
configFile: CONFIG_FILE,
coreExe: CORE_EXE,
coreExists: fs.existsSync(CORE_EXE),
}));
ipcMain.handle("core:start", () => {
app.isQuitting = false;
startCore();
setTimeout(() => setEmbeddedAuth(), 400);
return coreStarted();
});
ipcMain.handle("core:stop", () => {
stopCore();
return true;
});
ipcMain.handle("core:restart", () => {
restartCore();
setTimeout(() => setEmbeddedAuth(), 500);
return true;
});
ipcMain.handle("core:probe", async () => {
const p = await probeEmbedded();
return p;
});
ipcMain.handle("core:key", () => readAdminKey());
ipcMain.handle("settings:get", () => loadSettings());
ipcMain.handle("settings:set", (_, patch) => {
Object.assign(settings, patch);
// port change -> rewrite ONLY the listen line, preserving sources/keys/etc.
if (typeof patch.port === "number" && patch.port !== DEFAULT_PORT) {
try {
if (!fs.existsSync(CONFIG_FILE)) writeDefaultEmbeddedConfig();
let raw = fs.readFileSync(CONFIG_FILE, "utf-8");
raw = raw.replace(/^listen:.*$/m, "listen: 127.0.0.1:" + patch.port);
fs.writeFileSync(CONFIG_FILE, raw, "utf-8");
} catch (e) {
console.error("port rewrite failed:", e);
}
if (coreStarted()) restartCore();
}
if (typeof patch.autoStart === "boolean") applyAutoStart();
saveSettings();
setTimeout(() => setEmbeddedAuth(), 300);
return settings;
});
ipcMain.handle("settings:logFile", () => LOG_FILE);
// open paths in file manager
ipcMain.handle("shell:openPath", (_, p) => {
shell.openPath(p);
return true;
});
// window control from renderer uses preload -> ipcMain above
// ---------- bootstrap ----------
const gotLock = app.requestSingleInstanceLock();
if (gotLock) {
app.on("second-instance", () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
app.whenReady().then(() => {
installAuthRule();
loadSettings();
setupAutoStart();
setEmbeddedAuth();
createWindow();
createTray();
// start embedded core if not already running (another instance of llmsproxy)
setTimeout(async () => {
const p = await probeEmbedded();
if (!p.ok) {
startCore();
}
}, 600);
});
app.on("activate", () => {
if (mainWindow === null) createWindow();
});
app.on("before-quit", () => {
app.isQuitting = true;
stopCore();
try {
if (tray) tray.destroy();
} catch (e) {}
});
} else {
app.quit();
}

4048
cmd/gui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
cmd/gui/package.json Normal file
View File

@ -0,0 +1,73 @@
{
"name": "modelrouter-gui",
"version": "1.0.0",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",
"license": "Proprietary",
"scripts": {
"start": "electron . --no-sandbox",
"dev": "electron . --no-sandbox --dev",
"core:linux": "node scripts/prepare-core.js linux",
"core:win": "node scripts/prepare-core.js win",
"dist:linux": "npm run core:linux && electron-builder --linux deb AppImage",
"dist:debian": "npm run core:linux && electron-builder --linux deb",
"dist:win": "npm run core:win && electron-builder --win nsis",
"dist:dir": "npm run core:linux && electron-builder --dir"
},
"devDependencies": {
"electron": "^33.0.0",
"electron-builder": "^26.15.3"
},
"build": {
"appId": "com.modelrouter.gui",
"productName": "ModelRouter",
"linux": {
"target": [
"deb",
"rpm",
"AppImage"
],
"category": "Network",
"icon": "build/icon.png",
"maintainer": "ModelRouter",
"synopsis": "ModelRouter Desktop",
"extraResources": [
{
"from": "bin/llmsproxy",
"to": "bin/llmsproxy"
}
]
},
"directories": {
"output": "../build/gui-dist"
},
"mac": {
"target": "dmg",
"icon": "build/icon.png"
},
"win": {
"target": "nsis",
"icon": "build/icon.ico",
"extraResources": [
{
"from": "bin/llmsproxy.exe",
"to": "bin/llmsproxy.exe"
},
{
"from": "bin/lua51.dll",
"to": "bin/lua51.dll"
}
]
},
"files": [
"main.js",
"preload.js",
"renderer/**/*",
"build/**/*",
"!node_modules/**/*"
],
"asar": true
},
"homepage": "https://gitcode.com/JianFeeeee/ModelRouter"
}

27
cmd/gui/preload.js Normal file
View File

@ -0,0 +1,27 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("modelrouter", {
win: {
minimize: () => ipcRenderer.invoke("window:minimize"),
toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"),
close: () => ipcRenderer.invoke("window:close"),
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
},
core: {
state: () => ipcRenderer.invoke("core:state"),
start: () => ipcRenderer.invoke("core:start"),
stop: () => ipcRenderer.invoke("core:stop"),
restart: () => ipcRenderer.invoke("core:restart"),
probe: () => ipcRenderer.invoke("core:probe"),
key: () => ipcRenderer.invoke("core:key"),
onState: (cb) => ipcRenderer.on("core:state", (_e, d) => cb(d)),
},
settings: {
get: () => ipcRenderer.invoke("settings:get"),
set: (patch) => ipcRenderer.invoke("settings:set", patch),
logFile: () => ipcRenderer.invoke("settings:logFile"),
},
shell: {
openPath: (p) => ipcRenderer.invoke("shell:openPath", p),
},
});

198
cmd/gui/renderer/app.js Normal file
View 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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
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();

225
cmd/gui/renderer/index.html Normal file
View File

@ -0,0 +1,225 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src * data: blob:; img-src * data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; frame-src *;"
/>
<title>ModelRouter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- NapCat sakura/frost glassmorphism background -->
<div id="bgfx" aria-hidden="true">
<i class="blob b1"></i><i class="blob b2"></i><i class="blob b3"></i>
</div>
<!-- frameless draggable titlebar -->
<div class="titlebar">
<div class="titlebar-brand">
<span class="tb-logo">M</span>
<span class="tb-title">ModelRouter</span>
<span class="status-dot" id="conn-dot" title="内核状态"></span>
</div>
<div class="titlebar-right">
<button class="tb-btn" id="tb-settings" title="设置">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="15"
height="15"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
<button class="tb-btn" id="tb-min" title="最小化">
<svg
viewBox="0 0 24 24"
width="13"
height="13"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<line x1="4" y1="12" x2="20" y2="12" />
</svg>
</button>
<button class="tb-btn" id="tb-max" title="最大化">
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<rect x="5" y="5" width="14" height="14" rx="1.5" />
</svg>
</button>
<button class="tb-btn tb-close" id="tb-close" title="关闭">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</div>
<!-- side rail: autostart / silent toggles (Clash-Verge style) -->
<aside class="rail" id="rail">
<div class="rail-gap"></div>
<button
class="rail-btn tgl"
id="rail-autostart"
title="开机自启(点击切换)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" />
</svg>
<span class="rail-dot" id="rail-autostart-dot"></span>
</button>
<button
class="rail-btn tgl"
id="rail-silent"
title="静默启动(启动不弹窗仅驻托盘,点击切换)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3v10l4 4" />
<path d="M8 21h8" />
<path d="M12 8a8 8 0 1 0 8 8" />
</svg>
<span class="rail-dot" id="rail-silent-dot"></span>
</button>
<div class="rail-gap"></div>
<button class="rail-btn" id="rail-settings" title="设置">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
<button class="rail-btn tgl" id="rail-theme" title="切换亮色/暗色">
<span class="theme-ic"></span>
</button>
</aside>
<!-- main content: full-screen embedded WebUI (1:1 clone of the web UI) -->
<div id="app">
<div class="app-main">
<iframe
id="webui-frame"
class="webui-frame"
title="ModelRouter Web UI"
></iframe>
<div id="offline" class="offline" style="display: none">
<div class="off-card">
<div class="off-logo">M</div>
<div class="off-title">内核未运行</div>
<div class="off-sub">
内嵌 ModelRouter 网关尚未启动,启动后即可使用完整 WebUI
</div>
<button class="primary" id="off-start">启动内核</button>
</div>
</div>
<div id="console-loading" class="console-loading" style="display: none">
<div class="spinner"></div>
<div>正在启动内核…</div>
</div>
</div>
</div>
<!-- settings overlay -->
<div id="settings-overlay" class="overlay" style="display: none">
<div class="overlay-card">
<div class="overlay-head">
<h2>设置</h2>
<button class="tb-btn" id="set-close" title="关闭">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="form">
<div class="row">
<label>内核端口</label
><input id="set-port" type="number" min="1" max="65535" />
</div>
<div class="row">
<label class="toggle"
><input type="checkbox" id="set-autostart" /> 开机自启</label
><span class="hint">登录后自动启动内核</span>
</div>
<div class="row">
<label class="toggle"
><input type="checkbox" id="set-silent" /> 静默启动</label
><span class="hint">启动时不显示主窗口,仅驻留托盘</span>
</div>
<div class="row">
<label class="toggle"
><input type="checkbox" id="set-tray" /> 关闭时最小化到托盘</label
><span class="hint">点关闭按钮隐藏到系统托盘</span>
</div>
<div class="row actions">
<button class="ghost" id="set-dir">打开数据目录</button>
<button class="ghost" id="set-log">查看日志</button>
</div>
<div class="row actions right">
<button class="ghost" id="set-cancel">取消</button>
<button class="primary" id="set-save">保存</button>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script src="app.js"></script>
</body>
</html>

598
cmd/gui/renderer/style.css Normal file
View File

@ -0,0 +1,598 @@
/* =====================================================================
ModelRouter Desktop GUI — Clash-Verge-style shell
Content area = full-screen iframe embedding the real WebUI 1:1.
Palette mirrors the WebUI (NapCat sakura/frost/glassmorphism).
===================================================================== */
:root {
--primary: #ff7fac;
--primary-h: #f33b7c;
--primary-50: #fff0f5;
--primary-100: #ffe4e9;
--primary-200: #ffcdd9;
--primary-300: #ff9eb5;
--primary-400: #ff7fac;
--primary-500: #f33b7c;
--secondary: #88c0d0;
--secondary-h: #4c8dae;
--danger: #db3694;
--ok: #17a964;
--warn: #b7791f;
--bg-s1: #eef2ff;
--bg-s2: #ffffff;
--bg-s3: #ffe9f0;
--card: rgba(255, 255, 255, 0.62);
--card-solid: #fff;
--line: rgba(255, 127, 172, 0.18);
--line-strong: rgba(120, 90, 150, 0.22);
--fg: #3b3350;
--muted: #7c7a95;
--blob1: rgba(255, 127, 172, 0.45);
--blob2: rgba(136, 192, 208, 0.42);
--blob3: rgba(244, 114, 182, 0.3);
--sh-sm: 0 1px 2px rgba(70, 50, 110, 0.06);
--sh-md: 0 6px 20px rgba(120, 90, 160, 0.13);
--sh-lg: 0 18px 46px rgba(120, 90, 160, 0.22);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
html[data-theme="dark"] {
--primary: #f54180;
--primary-h: #f871a0;
--primary-50: #310413;
--primary-100: #610726;
--primary-200: #920b3a;
--primary-300: #c20e4d;
--primary-400: #f31260;
--primary-500: #f54180;
--secondary: #5e9fbf;
--secondary-h: #88c0d0;
--danger: #db3694;
--ok: #4f7cff;
--warn: #e0b25c;
--bg-s1: #14121c;
--bg-s2: #1a1824;
--bg-s3: #221a28;
--card: rgba(34, 31, 50, 0.55);
--card-solid: #241f38;
--line: rgba(255, 127, 172, 0.16);
--line-strong: rgba(200, 180, 235, 0.18);
--fg: #ece6f5;
--muted: #a09db8;
--blob1: rgba(255, 79, 160, 0.32);
--blob2: rgba(100, 180, 215, 0.26);
--blob3: rgba(219, 54, 148, 0.22);
--sh-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--sh-md: 0 8px 26px rgba(0, 0, 0, 0.45);
--sh-lg: 0 20px 52px rgba(0, 0, 0, 0.55);
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
color: var(--fg);
overflow: hidden;
font:
13px / 1.5 "Quicksand",
"Nunito",
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"PingFang SC",
"Microsoft YaHei",
sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background: var(--bg-s1);
}
button {
font: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input {
font: inherit;
color: var(--fg);
}
::selection {
background: #ffcdba;
color: #fff;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 182, 193, 0.45);
border-radius: 4px;
}
/* animated gradient-mesh background (NapCat PageBackground) */
#bgfx {
position: fixed;
inset: 0;
z-index: -10;
overflow: hidden;
background: linear-gradient(135deg, var(--bg-s1), var(--bg-s2), var(--bg-s3));
}
#bgfx i {
position: absolute;
border-radius: 50%;
filter: blur(64px);
will-change: transform;
opacity: 0.6;
}
#bgfx .b1 {
top: -10%;
left: -8%;
width: 560px;
height: 560px;
background: var(--blob1);
animation: drift1 30s ease-in-out infinite alternate;
}
#bgfx .b2 {
top: 16%;
right: -10%;
width: 440px;
height: 440px;
background: var(--blob2);
animation: drift2 36s ease-in-out infinite alternate;
}
#bgfx .b3 {
bottom: -12%;
left: 24%;
width: 620px;
height: 620px;
background: var(--blob3);
animation: drift3 42s ease-in-out infinite alternate;
}
@keyframes drift1 {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(30px, -20px) scale(1.05);
}
}
@keyframes drift2 {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(-25px, 18px) scale(1.03);
}
}
@keyframes drift3 {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(18px, 25px) scale(1.04);
}
}
/* ---- frameless titlebar ---- */
.titlebar {
height: 36px;
display: flex;
align-items: center;
padding: 0 8px 0 14px;
-webkit-app-region: drag;
user-select: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.5),
rgba(255, 255, 255, 0.16)
);
backdrop-filter: blur(14px) saturate(1.4);
-webkit-backdrop-filter: blur(14px) saturate(1.4);
border-bottom: 1px solid var(--line);
position: relative;
z-index: 30;
}
html[data-theme="dark"] .titlebar {
background: linear-gradient(
180deg,
rgba(26, 23, 38, 0.7),
rgba(26, 23, 38, 0.42)
);
}
.titlebar-brand {
display: flex;
align-items: center;
gap: 9px;
}
.tb-logo {
width: 20px;
height: 20px;
border-radius: 6px;
background: linear-gradient(
135deg,
#3f6ef5,
#6a8ffb
); /* matches site favicon */
color: #fff;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.tb-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--fg);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #c8c6d2;
}
.status-dot.ok {
background: var(--ok);
box-shadow: 0 0 8px rgba(23, 169, 100, 0.55);
}
.status-dot.bad {
background: var(--danger);
}
.titlebar-right {
margin-left: auto;
display: flex;
align-items: center;
height: 36px;
-webkit-app-region: no-drag;
gap: 2px;
}
.tb-btn {
width: 34px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 7px;
color: var(--muted);
transition:
background 0.15s,
color 0.15s;
}
.tb-btn:hover {
background: var(--primary-100);
color: var(--fg);
}
.tb-btn.tb-close:hover {
background: var(--danger);
color: #fff;
}
/* ---- content area ---- */
#app {
position: absolute;
inset: 36px 0 0 0;
display: flex;
}
#rail {
flex: 0 0 56px;
width: 56px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 0;
background: var(--card2, rgba(255, 255, 255, 0.45));
border-right: 1px solid var(--line);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.rail-gap {
flex: 1;
}
.rail-btn {
position: relative;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 11px;
color: var(--muted);
transition: all 0.15s;
}
.rail-btn svg {
width: 18px;
height: 18px;
}
.rail-btn:hover {
color: var(--fg);
background: var(--primary-100);
}
.rail-btn.active {
color: var(--primary-h);
background: var(--primary-100);
}
/* toggle status dot (top-right corner of the button) */
.rail-dot {
position: absolute;
top: 5px;
right: 5px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #c8c6d2;
border: 1.5px solid var(--card-solid, #fff);
box-sizing: content-box;
transition: background 0.15s;
}
.rail-dot.on {
background: var(--ok, #17a964);
box-shadow: 0 0 6px rgba(23, 169, 100, 0.6);
}
.theme-ic {
display: flex;
align-items: center;
justify-content: center;
}
.theme-ic svg {
width: 16px;
height: 16px;
}
/* content column: iframe sits to the right of the rail */
.app-main {
flex: 1;
min-width: 0;
position: relative;
}
.webui-frame {
width: 100%;
height: 100%;
border: none;
background: #fff;
}
html[data-theme="dark"] .webui-frame {
background: #1a1824;
}
/* ---- offline placeholder ---- */
.offline {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.off-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 38px 46px;
background: var(--card);
border: 1px solid var(--line-strong);
border-radius: 20px;
box-shadow: var(--sh-lg);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.off-logo {
width: 52px;
height: 52px;
border-radius: 15px;
background: linear-gradient(
135deg,
#3f6ef5,
#6a8ffb
); /* matches site favicon */
color: #fff;
font-size: 26px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--sh-md);
}
.off-title {
font-size: 17px;
font-weight: 700;
}
.off-sub {
font-size: 12.5px;
color: var(--muted);
max-width: 340px;
text-align: center;
}
.off-card button {
padding: 10px 26px;
border-radius: 10px;
font-weight: 600;
margin-top: 6px;
}
.off-card .primary {
background: var(--primary);
color: #fff;
}
.off-card .primary:hover {
background: var(--primary-h);
}
/* loading spinner */
.console-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: var(--muted);
font-size: 13px;
z-index: 5;
}
.spinner {
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid var(--primary-100);
border-top-color: var(--primary-h);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---- settings overlay ---- */
.overlay {
position: absolute;
inset: 36px 0 0 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 40;
background: rgba(30, 24, 48, 0.18);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
html[data-theme="dark"] .overlay {
background: rgba(10, 8, 18, 0.5);
}
.overlay-card {
width: 400px;
max-width: 92%;
background: var(--card-solid);
border: 1px solid var(--line-strong);
border-radius: 18px;
box-shadow: var(--sh-lg);
padding: 22px 24px;
animation: popIn 0.18s var(--ease-spring);
}
@keyframes popIn {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
.overlay-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.overlay-head h2 {
margin: 0;
font-size: 17px;
}
.form .row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 0;
border-bottom: 1px solid var(--line);
}
.form .row label {
flex: 0 0 110px;
font-size: 13px;
color: var(--muted);
}
.form .row .toggle {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: var(--fg);
font-weight: 500;
}
.form .row .toggle input {
accent-color: var(--primary);
width: 15px;
height: 15px;
}
.form .row .hint {
font-size: 11px;
color: var(--muted);
}
.form input[type="number"] {
width: 120px;
padding: 7px 10px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--bg-s2);
outline: none;
}
.form input[type="number"]:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-100);
}
.form .actions {
display: flex;
justify-content: flex-start;
gap: 9px;
border-bottom: none;
padding-top: 12px;
}
.form .actions.right {
justify-content: flex-end;
}
.form .actions button {
padding: 9px 18px;
border-radius: 9px;
font-weight: 600;
transition: all 0.15s;
}
.form .actions .primary {
background: var(--primary);
color: #fff;
}
.form .actions .primary:hover {
background: var(--primary-h);
}
.form .actions .ghost {
background: var(--bg-s2);
border: 1px solid var(--line-strong);
color: var(--muted);
}
.form .actions .ghost:hover {
background: var(--primary-50);
color: var(--fg);
}
/* ---- toast ---- */
#toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 200;
padding: 11px 18px;
border-radius: 12px;
background: var(--card-solid);
color: var(--fg);
font-size: 13px;
box-shadow: var(--sh-lg);
border: 1px solid var(--line);
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: all 0.25s var(--ease-spring);
}
#toast.show {
opacity: 1;
transform: none;
}
#toast.err {
border-color: var(--danger);
}

View File

@ -0,0 +1,74 @@
#!/usr/bin/env node
// Build the embedded llmsproxy core(s) for the GUI package.
// node prepare-core.js -> linux (native, luajit) + win (cross, best effort)
// node prepare-core.js linux -> linux only
// node prepare-core.js win -> win only
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const guiDir = __dirname.replace(/scripts$/, "");
const binDir = path.join(guiDir, "bin");
const projectRoot = path.join(guiDir, "..", "..");
function buildLinux() {
const out = path.join(binDir, "llmsproxy");
console.log("[prepare-core] building llmsproxy (linux/luajit)...");
fs.mkdirSync(binDir, { recursive: true });
execFileSync(
"go",
["build", "-tags", "luajit", "-trimpath", "-o", out, "./cmd/llmsproxy"],
{ cwd: projectRoot, stdio: "inherit" },
);
return out;
}
function buildWin() {
const out = path.join(binDir, "llmsproxy.exe");
console.log(
"[prepare-core] cross-building llmsproxy.exe (windows/amd64, luajit)...",
);
fs.mkdirSync(binDir, { recursive: true });
const cc = process.env.MINGW_CC || "x86_64-w64-mingw32-gcc";
// friendly hint when the mingw cross-compiler is missing
try {
execFileSync(cc + " --version", { shell: true, stdio: "ignore" });
} catch (e) {
console.error(
"[prepare-core] MISSING mingw-w64 cross-compiler (" + cc + ").",
);
console.error(" Install it: sudo apt install gcc-mingw-w64-x86-64");
console.error(" Then re-run: npm run core:win (or make gui-win)");
process.exit(1);
}
execFileSync(
"go",
["build", "-tags", "luajit", "-trimpath", "-o", out, "./cmd/llmsproxy"],
{
cwd: projectRoot,
stdio: "inherit",
env: Object.assign({}, process.env, {
GOOS: "windows",
GOARCH: "amd64",
CGO_ENABLED: "1",
CC: cc,
}),
},
);
return out;
}
const which = process.argv[2] || "both";
const results = [];
try {
if (which === "linux" || which === "both") results.push(buildLinux());
} catch (e) {
console.error("[prepare-core] linux build failed:", e.message);
}
try {
if (which === "win" || which === "both") results.push(buildWin());
} catch (e) {
console.error("[prepare-core] win build failed:", e.message);
}
if (results.length === 0) process.exit(1);
results.forEach((p) => console.log("[prepare-core] core ready: " + p));

View File

@ -16,6 +16,17 @@ adapter.headers = {
-- zen 上游 schema 只接受 text content part无视觉/音频能力):多模态 part
-- image_url / input_audio / file 等)一律剥离;因此失去全部 content 的
-- 消息整条丢弃,避免上游 "unknown variant `image_url`, expected `text`"。
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder
-- OpenAI 的 developer及 function 等)不在其中,直接透传会触发上游
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
local ROLE_WHITELIST = {
system = true,
user = true,
assistant = true,
tool = true,
latest_reminder = true,
}
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
@ -24,6 +35,9 @@ function adapter.transform_request(raw_body)
if req.messages then
local kept = {}
for _, msg in ipairs(req.messages) do
if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then
msg.role = "system"
end
msg.reasoning_content = nil
local drop = false
if type(msg.content) == "table" then

View File

@ -302,3 +302,71 @@ func TestMultimodalTransform(t *testing.T) {
t.Fatalf("openai passthrough lost multimodal content: %s", po)
}
}
func TestOpenCodeAdapterNormalizesDeveloperRole(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
raw := `{"model":"zen","messages":[` +
`{"role":"developer","content":"be concise"},` +
`{"role":"user","content":"hi"},` +
`{"role":"assistant","content":"hello"},` +
`{"role":"function","content":"{\"a\":1}"},` +
`{"role":"latest_reminder","content":"remind"}]}`
out, err := vm.Transform("opencode", "transform_request", raw)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
var req struct {
Messages []struct {
Role string `json:"role"`
} `json:"messages"`
}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out)
}
want := []string{"system", "user", "assistant", "system", "latest_reminder"}
if len(req.Messages) != len(want) {
t.Fatalf("got %d messages, want %d: %s", len(req.Messages), len(want), out)
}
for i, w := range want {
if req.Messages[i].Role != w {
t.Errorf("message %d role = %q, want %q", i, req.Messages[i].Role, w)
}
}
}
func TestOpenCodeAdapterKeepsWhitelistedRolesAndDropsMultimodal(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err)
}
defer vm.Stop()
raw := `{"messages":[` +
`{"role":"system","content":"sys"},` +
`{"role":"user","content":[{"type":"text","text":"keep"},{"type":"image_url","url":"x"}]},` +
`{"role":"user","content":[{"type":"image_url","url":"x"}]}]}`
out, err := vm.Transform("opencode", "transform_request", raw)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
var req struct {
Messages []struct {
Role string `json:"role"`
Content any `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out)
}
if len(req.Messages) != 2 {
t.Fatalf("got %d messages, want 2 (multimodal-only dropped): %s", len(req.Messages), out)
}
if req.Messages[0].Role != "system" || req.Messages[1].Role != "user" {
t.Fatalf("unexpected roles: %s", out)
}
}